pycforge 0.15.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (221) hide show
  1. pycforge/__init__.py +8 -0
  2. pycforge/__main__.py +2 -0
  3. pycforge/_version.py +1 -0
  4. pycforge/converter/__init__.py +0 -0
  5. pycforge/converter/analysis/__init__.py +5 -0
  6. pycforge/converter/analysis/functions.py +723 -0
  7. pycforge/converter/analysis/model.py +158 -0
  8. pycforge/converter/analysis/planning.py +464 -0
  9. pycforge/converter/analysis/stage.py +1976 -0
  10. pycforge/converter/analysis/symbols.py +237 -0
  11. pycforge/converter/analysis/validation.py +2166 -0
  12. pycforge/converter/c_output/__init__.py +2 -0
  13. pycforge/converter/c_output/conformance.py +297 -0
  14. pycforge/converter/c_output/renderer.py +220 -0
  15. pycforge/converter/conditional_regions/__init__.py +71 -0
  16. pycforge/converter/conditional_regions/analysis.py +481 -0
  17. pycforge/converter/conditional_regions/lowering.py +579 -0
  18. pycforge/converter/conditional_regions/model.py +243 -0
  19. pycforge/converter/conditional_regions/validation.py +690 -0
  20. pycforge/converter/containers/__init__.py +19 -0
  21. pycforge/converter/containers/analysis.py +542 -0
  22. pycforge/converter/containers/lowering.py +290 -0
  23. pycforge/converter/containers/model.py +146 -0
  24. pycforge/converter/contracts/__init__.py +0 -0
  25. pycforge/converter/contracts/configuration.py +178 -0
  26. pycforge/converter/contracts/identifiers.py +68 -0
  27. pycforge/converter/contracts/versions.py +71 -0
  28. pycforge/converter/core/__init__.py +0 -0
  29. pycforge/converter/core/artifact_io.py +87 -0
  30. pycforge/converter/core/cancellation.py +7 -0
  31. pycforge/converter/core/canonicalization.py +254 -0
  32. pycforge/converter/core/context.py +13 -0
  33. pycforge/converter/core/diagnostics.py +95 -0
  34. pycforge/converter/core/enums.py +23 -0
  35. pycforge/converter/core/fingerprint.py +22 -0
  36. pycforge/converter/core/pipeline.py +8 -0
  37. pycforge/converter/core/progress.py +25 -0
  38. pycforge/converter/core/request.py +50 -0
  39. pycforge/converter/core/resource_policy.py +31 -0
  40. pycforge/converter/core/result.py +23 -0
  41. pycforge/converter/core/serialization.py +36 -0
  42. pycforge/converter/core/stage.py +10 -0
  43. pycforge/converter/core/stage_artifact.py +80 -0
  44. pycforge/converter/core/stage_outcome.py +15 -0
  45. pycforge/converter/decision_trace/__init__.py +0 -0
  46. pycforge/converter/decision_trace/recorder.py +52 -0
  47. pycforge/converter/facade.py +459 -0
  48. pycforge/converter/frontend/__init__.py +4 -0
  49. pycforge/converter/frontend/normalizer.py +59 -0
  50. pycforge/converter/frontend/parser.py +20 -0
  51. pycforge/converter/frontend/source_document.py +93 -0
  52. pycforge/converter/frontend/stages.py +518 -0
  53. pycforge/converter/frontend/validation.py +76 -0
  54. pycforge/converter/io/__init__.py +0 -0
  55. pycforge/converter/io/atomic_writer.py +33 -0
  56. pycforge/converter/ir/__init__.py +1 -0
  57. pycforge/converter/ir/c_ir/__init__.py +4 -0
  58. pycforge/converter/ir/c_ir/builder.py +26 -0
  59. pycforge/converter/ir/c_ir/model.py +318 -0
  60. pycforge/converter/ir/c_ir/serialization.py +88 -0
  61. pycforge/converter/ir/c_ir/validation.py +834 -0
  62. pycforge/converter/ir/python_ir/__init__.py +17 -0
  63. pycforge/converter/ir/python_ir/nodes.py +86 -0
  64. pycforge/converter/keyword_calls/__init__.py +68 -0
  65. pycforge/converter/keyword_calls/analysis.py +458 -0
  66. pycforge/converter/keyword_calls/lowering.py +271 -0
  67. pycforge/converter/keyword_calls/model.py +304 -0
  68. pycforge/converter/keyword_calls/validation.py +802 -0
  69. pycforge/converter/keyword_only_calls/__init__.py +68 -0
  70. pycforge/converter/keyword_only_calls/analysis.py +640 -0
  71. pycforge/converter/keyword_only_calls/lowering.py +376 -0
  72. pycforge/converter/keyword_only_calls/model.py +328 -0
  73. pycforge/converter/keyword_only_calls/validation.py +1357 -0
  74. pycforge/converter/lowering.py +1000 -0
  75. pycforge/converter/modules/__init__.py +13 -0
  76. pycforge/converter/modules/analysis.py +1250 -0
  77. pycforge/converter/modules/lowering.py +200 -0
  78. pycforge/converter/modules/model.py +73 -0
  79. pycforge/converter/modules/stage.py +192 -0
  80. pycforge/converter/numeric_semantics/__init__.py +23 -0
  81. pycforge/converter/numeric_semantics/analysis.py +420 -0
  82. pycforge/converter/numeric_semantics/lowering.py +153 -0
  83. pycforge/converter/numeric_semantics/model.py +123 -0
  84. pycforge/converter/records/__init__.py +34 -0
  85. pycforge/converter/records/analysis.py +1394 -0
  86. pycforge/converter/records/lowering.py +393 -0
  87. pycforge/converter/records/model.py +319 -0
  88. pycforge/converter/rules/__init__.py +3 -0
  89. pycforge/converter/support_templates/__init__.py +49 -0
  90. pycforge/converter/support_templates/assembly.py +106 -0
  91. pycforge/converter/support_templates/factories.py +348 -0
  92. pycforge/converter/support_templates/model.py +159 -0
  93. pycforge/converter/support_templates/registry.py +388 -0
  94. pycforge/converter/telemetry/__init__.py +0 -0
  95. pycforge/converter/telemetry/sink.py +39 -0
  96. pycforge/ide/__init__.py +19 -0
  97. pycforge/ide/__main__.py +5 -0
  98. pycforge/ide/_worker_event_codec.py +276 -0
  99. pycforge/ide/_worker_protocol_json.py +197 -0
  100. pycforge/ide/_worker_protocol_types.py +90 -0
  101. pycforge/ide/_worker_request_codec.py +200 -0
  102. pycforge/ide/_worker_request_data.py +260 -0
  103. pycforge/ide/_worker_result_data.py +343 -0
  104. pycforge/ide/action_contract.py +469 -0
  105. pycforge/ide/action_validation.py +229 -0
  106. pycforge/ide/command_palette.py +223 -0
  107. pycforge/ide/controller.py +904 -0
  108. pycforge/ide/controller_conversion.py +546 -0
  109. pycforge/ide/controller_io.py +205 -0
  110. pycforge/ide/editor.py +951 -0
  111. pycforge/ide/editor_commands_qt.py +274 -0
  112. pycforge/ide/editor_lexical.py +43 -0
  113. pycforge/ide/editor_sidebars.py +236 -0
  114. pycforge/ide/editor_syntax.py +243 -0
  115. pycforge/ide/find_replace.py +535 -0
  116. pycforge/ide/icons.py +91 -0
  117. pycforge/ide/io_service.py +196 -0
  118. pycforge/ide/model.py +164 -0
  119. pycforge/ide/panels.py +903 -0
  120. pycforge/ide/positions.py +220 -0
  121. pycforge/ide/process_worker.py +130 -0
  122. pycforge/ide/qt.py +230 -0
  123. pycforge/ide/qt_actions.py +418 -0
  124. pycforge/ide/qt_close.py +169 -0
  125. pycforge/ide/qt_command_palette.py +297 -0
  126. pycforge/ide/qt_contract.py +220 -0
  127. pycforge/ide/qt_documents.py +590 -0
  128. pycforge/ide/qt_editor_buffers.py +113 -0
  129. pycforge/ide/qt_editor_surfaces.py +112 -0
  130. pycforge/ide/qt_menus.py +324 -0
  131. pycforge/ide/qt_projection.py +500 -0
  132. pycforge/ide/qt_shell.py +348 -0
  133. pycforge/ide/qt_shell_interactions.py +320 -0
  134. pycforge/ide/qt_state.py +393 -0
  135. pycforge/ide/qt_workspace_features.py +398 -0
  136. pycforge/ide/qt_workspace_navigation.py +208 -0
  137. pycforge/ide/qt_workspace_observers.py +173 -0
  138. pycforge/ide/qt_workspace_panels.py +545 -0
  139. pycforge/ide/qt_workspace_widgets.py +228 -0
  140. pycforge/ide/resources/icons/about.svg +1 -0
  141. pycforge/ide/resources/icons/add-document.svg +1 -0
  142. pycforge/ide/resources/icons/brand-mark.svg +1 -0
  143. pycforge/ide/resources/icons/bundle-search.svg +1 -0
  144. pycforge/ide/resources/icons/cancel.svg +1 -0
  145. pycforge/ide/resources/icons/check.svg +1 -0
  146. pycforge/ide/resources/icons/chevron-right.svg +1 -0
  147. pycforge/ide/resources/icons/close.svg +1 -0
  148. pycforge/ide/resources/icons/collapse-all.svg +1 -0
  149. pycforge/ide/resources/icons/command-palette.svg +1 -0
  150. pycforge/ide/resources/icons/convert.svg +1 -0
  151. pycforge/ide/resources/icons/copy.svg +1 -0
  152. pycforge/ide/resources/icons/cut.svg +1 -0
  153. pycforge/ide/resources/icons/decision-trace.svg +1 -0
  154. pycforge/ide/resources/icons/details.svg +1 -0
  155. pycforge/ide/resources/icons/diagnostics.svg +1 -0
  156. pycforge/ide/resources/icons/duplicate-line.svg +1 -0
  157. pycforge/ide/resources/icons/exit.svg +1 -0
  158. pycforge/ide/resources/icons/expand-all.svg +1 -0
  159. pycforge/ide/resources/icons/export.svg +1 -0
  160. pycforge/ide/resources/icons/find.svg +1 -0
  161. pycforge/ide/resources/icons/go-to-line.svg +1 -0
  162. pycforge/ide/resources/icons/go-to-output.svg +1 -0
  163. pycforge/ide/resources/icons/go-to-source.svg +1 -0
  164. pycforge/ide/resources/icons/history.svg +1 -0
  165. pycforge/ide/resources/icons/indent.svg +1 -0
  166. pycforge/ide/resources/icons/link-c.svg +1 -0
  167. pycforge/ide/resources/icons/mappings.svg +1 -0
  168. pycforge/ide/resources/icons/module.svg +1 -0
  169. pycforge/ide/resources/icons/move-down.svg +1 -0
  170. pycforge/ide/resources/icons/move-line-down.svg +1 -0
  171. pycforge/ide/resources/icons/move-line-up.svg +1 -0
  172. pycforge/ide/resources/icons/move-up.svg +1 -0
  173. pycforge/ide/resources/icons/next.svg +1 -0
  174. pycforge/ide/resources/icons/open.svg +1 -0
  175. pycforge/ide/resources/icons/outdent.svg +1 -0
  176. pycforge/ide/resources/icons/outline.svg +1 -0
  177. pycforge/ide/resources/icons/paste.svg +1 -0
  178. pycforge/ide/resources/icons/previous.svg +1 -0
  179. pycforge/ide/resources/icons/primary-module.svg +1 -0
  180. pycforge/ide/resources/icons/redo.svg +1 -0
  181. pycforge/ide/resources/icons/remove-document.svg +1 -0
  182. pycforge/ide/resources/icons/replace.svg +1 -0
  183. pycforge/ide/resources/icons/save-as.svg +1 -0
  184. pycforge/ide/resources/icons/save.svg +1 -0
  185. pycforge/ide/resources/icons/select-all.svg +1 -0
  186. pycforge/ide/resources/icons/settings.svg +1 -0
  187. pycforge/ide/resources/icons/show-c.svg +1 -0
  188. pycforge/ide/resources/icons/split-view.svg +1 -0
  189. pycforge/ide/resources/icons/summary.svg +1 -0
  190. pycforge/ide/resources/icons/telemetry.svg +1 -0
  191. pycforge/ide/resources/icons/toggle-comment.svg +1 -0
  192. pycforge/ide/resources/icons/toggle-fold.svg +1 -0
  193. pycforge/ide/resources/icons/undo.svg +1 -0
  194. pycforge/ide/resources/icons/whitespace.svg +1 -0
  195. pycforge/ide/revisions.py +400 -0
  196. pycforge/ide/search_service.py +280 -0
  197. pycforge/ide/session_history.py +150 -0
  198. pycforge/ide/source_editing.py +417 -0
  199. pycforge/ide/source_structure.py +558 -0
  200. pycforge/ide/source_structure_async.py +191 -0
  201. pycforge/ide/supervisor.py +503 -0
  202. pycforge/ide/theme.py +119 -0
  203. pycforge/ide/theme_stylesheet.py +598 -0
  204. pycforge/ide/theme_workspace_stylesheet.py +155 -0
  205. pycforge/ide/visual_tokens.py +142 -0
  206. pycforge/ide/worker_protocol.py +80 -0
  207. pycforge/ide/workspace_search.py +462 -0
  208. pycforge/ide/workspace_session.py +256 -0
  209. pycforge/laboratory/__init__.py +6 -0
  210. pycforge/laboratory/audits.py +2244 -0
  211. pycforge/laboratory/checkpoint_e.py +2685 -0
  212. pycforge/laboratory/cli.py +119 -0
  213. pycforge/laboratory/exit_codes.py +11 -0
  214. pycforge/laboratory/keyword_audit.py +661 -0
  215. pycforge/laboratory/keyword_only_audit.py +481 -0
  216. pycforge-0.15.2.dist-info/METADATA +209 -0
  217. pycforge-0.15.2.dist-info/RECORD +221 -0
  218. pycforge-0.15.2.dist-info/WHEEL +5 -0
  219. pycforge-0.15.2.dist-info/entry_points.txt +5 -0
  220. pycforge-0.15.2.dist-info/licenses/LICENSE +674 -0
  221. pycforge-0.15.2.dist-info/top_level.txt +1 -0
@@ -0,0 +1,274 @@
1
+ """Qt adapter for bounded, undoable Python source-editor commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable
6
+
7
+ from PyQt5.QtGui import QTextCursor, QTextOption
8
+
9
+ from .source_editing import (
10
+ SourceEditResult,
11
+ duplicate_lines,
12
+ indent_lines,
13
+ move_lines_down,
14
+ move_lines_up,
15
+ outdent_lines,
16
+ toggle_python_line_comment,
17
+ )
18
+
19
+
20
+ FOLD_BLOCK_LIMIT = 2_048
21
+ FOLD_TOTAL_BLOCK_LIMIT = 8_192
22
+ MAX_FOLDS = 128
23
+
24
+
25
+ def _python_offset(text: str, qt_offset: int) -> int:
26
+ """Convert a clamped Qt UTF-16 position to a Python string offset."""
27
+
28
+ target = max(0, int(qt_offset))
29
+ if text.isascii():
30
+ return min(len(text), target)
31
+ units = 0
32
+ for index, character in enumerate(text):
33
+ width = 2 if ord(character) > 0xFFFF else 1
34
+ if units + width > target:
35
+ return index
36
+ units += width
37
+ if units == target:
38
+ return index + 1
39
+ return len(text)
40
+
41
+
42
+ def _qt_offset(text: str, python_offset: int) -> int:
43
+ position = min(len(text), max(0, int(python_offset)))
44
+ if text.isascii():
45
+ return position
46
+ return len(text[:position].encode("utf-16-le")) // 2
47
+
48
+
49
+ def _changed_span(before: str, after: str) -> tuple[int, int, int]:
50
+ """Return common prefix, old suffix start, and new suffix start."""
51
+
52
+ prefix = 0
53
+ limit = min(len(before), len(after))
54
+ while prefix < limit and before[prefix] == after[prefix]:
55
+ prefix += 1
56
+ old_suffix = len(before)
57
+ new_suffix = len(after)
58
+ while (
59
+ old_suffix > prefix
60
+ and new_suffix > prefix
61
+ and before[old_suffix - 1] == after[new_suffix - 1]
62
+ ):
63
+ old_suffix -= 1
64
+ new_suffix -= 1
65
+ return prefix, old_suffix, new_suffix
66
+
67
+
68
+ def _indentation(text: str) -> int:
69
+ return len(text) - len(text.lstrip(" \t"))
70
+
71
+
72
+ class SourceCommandMixin:
73
+ """Editing, navigation, whitespace, and folding for ``CodeEditor``."""
74
+
75
+ def _initialize_source_commands(
76
+ self,
77
+ *,
78
+ highlighting_enabled: bool,
79
+ ) -> None:
80
+ self._highlighting_enabled = bool(highlighting_enabled)
81
+ self._whitespace_visible = False
82
+ self._folded_blocks: dict[int, tuple[object, ...]] = {}
83
+ if not self._highlighting_enabled:
84
+ self._highlighter.setDocument(None)
85
+
86
+ @property
87
+ def whitespace_visible(self) -> bool:
88
+ return self._whitespace_visible
89
+
90
+ def bind_text_document(self, document) -> None:
91
+ """Bind a pre-existing Qt document while retaining editor behavior."""
92
+
93
+ if document is self.document():
94
+ return
95
+ self.unfold_all()
96
+ self._highlighter.setDocument(None)
97
+ # QSyntaxHighlighter is initially parented to the editor's original
98
+ # QTextDocument. Reparent it before QPlainTextEdit releases that
99
+ # document so shared-buffer rebinding cannot delete the highlighter.
100
+ self._highlighter.setParent(self)
101
+ super().setDocument(document)
102
+ self._update_large_file_mode()
103
+ if self._highlighting_enabled and not self.large_file_mode:
104
+ self._highlighter.setDocument(document)
105
+ self.clear_markers()
106
+ self._apply_whitespace_option()
107
+ self._update_viewport_margins()
108
+ self._refresh_extra_selections()
109
+
110
+ def duplicate_line_or_selection(self) -> bool:
111
+ return self._apply_source_operation(duplicate_lines)
112
+
113
+ def move_source_lines_up(self) -> bool:
114
+ return self._apply_source_operation(move_lines_up)
115
+
116
+ def move_source_lines_down(self) -> bool:
117
+ return self._apply_source_operation(move_lines_down)
118
+
119
+ def indent_source_lines(self) -> bool:
120
+ return self._apply_source_operation(indent_lines)
121
+
122
+ def outdent_source_lines(self) -> bool:
123
+ return self._apply_source_operation(outdent_lines)
124
+
125
+ def toggle_source_comment(self) -> bool:
126
+ return self._apply_source_operation(toggle_python_line_comment)
127
+
128
+ def _apply_source_operation(
129
+ self,
130
+ operation: Callable[[str, int, int], SourceEditResult],
131
+ ) -> bool:
132
+ if self.isReadOnly() or self.language != "python":
133
+ return False
134
+ before = self.toPlainText()
135
+ active = self.textCursor()
136
+ start = _python_offset(before, active.selectionStart())
137
+ end = _python_offset(before, active.selectionEnd())
138
+ result = operation(before, start, end)
139
+ if result.text == before:
140
+ return False
141
+ prefix, old_suffix, new_suffix = _changed_span(
142
+ before,
143
+ result.text,
144
+ )
145
+ cursor = QTextCursor(self.document())
146
+ cursor.beginEditBlock()
147
+ cursor.setPosition(_qt_offset(before, prefix))
148
+ cursor.setPosition(
149
+ _qt_offset(before, old_suffix),
150
+ QTextCursor.KeepAnchor,
151
+ )
152
+ cursor.insertText(result.text[prefix:new_suffix])
153
+ cursor.endEditBlock()
154
+ selection = QTextCursor(self.document())
155
+ selection.setPosition(
156
+ _qt_offset(result.text, result.selection_start)
157
+ )
158
+ selection.setPosition(
159
+ _qt_offset(result.text, result.selection_end),
160
+ QTextCursor.KeepAnchor,
161
+ )
162
+ self.setTextCursor(selection)
163
+ self.ensureCursorVisible()
164
+ return True
165
+
166
+ def go_to_line_number(self, line: int) -> int:
167
+ """Go to a clamped one-based line without copying source text."""
168
+
169
+ if isinstance(line, bool) or not isinstance(line, int):
170
+ raise TypeError("line must be an integer")
171
+ maximum = max(1, self.document().blockCount())
172
+ resolved = min(maximum, max(1, line))
173
+ block = self.document().findBlockByNumber(resolved - 1)
174
+ cursor = QTextCursor(block)
175
+ self.setTextCursor(cursor)
176
+ self.centerCursor()
177
+ self.setFocus()
178
+ return resolved
179
+
180
+ def set_whitespace_visible(self, visible: bool) -> None:
181
+ enabled = bool(visible)
182
+ if enabled == self._whitespace_visible:
183
+ return
184
+ self._whitespace_visible = enabled
185
+ self._apply_whitespace_option()
186
+
187
+ def _apply_whitespace_option(self) -> None:
188
+ option = self.document().defaultTextOption()
189
+ flags = option.flags()
190
+ markers = (
191
+ QTextOption.ShowTabsAndSpaces
192
+ | QTextOption.ShowLineAndParagraphSeparators
193
+ )
194
+ option.setFlags(flags | markers if self._whitespace_visible else flags & ~markers)
195
+ self.document().setDefaultTextOption(option)
196
+ self.viewport().update()
197
+
198
+ def toggle_fold_at_cursor(self) -> bool:
199
+ """Fold or unfold one indentation region under absolute budgets."""
200
+
201
+ if self.language != "python":
202
+ return False
203
+ start = self.textCursor().block()
204
+ key = start.position()
205
+ retained = self._folded_blocks.pop(key, None)
206
+ if retained is not None:
207
+ self._set_blocks_visible(retained, True)
208
+ return True
209
+ if (
210
+ len(self._folded_blocks) >= MAX_FOLDS
211
+ or sum(len(value) for value in self._folded_blocks.values())
212
+ >= FOLD_TOTAL_BLOCK_LIMIT
213
+ ):
214
+ return False
215
+ base = _indentation(start.text())
216
+ block = start.next()
217
+ candidates: list[object] = []
218
+ found_body = False
219
+ while block.isValid() and len(candidates) < FOLD_BLOCK_LIMIT:
220
+ text = block.text()
221
+ if text.strip():
222
+ indent = _indentation(text)
223
+ if indent <= base:
224
+ break
225
+ found_body = True
226
+ candidates.append(block)
227
+ block = block.next()
228
+ if not found_body:
229
+ return False
230
+ remaining = FOLD_TOTAL_BLOCK_LIMIT - sum(
231
+ len(value) for value in self._folded_blocks.values()
232
+ )
233
+ retained = tuple(candidates[:remaining])
234
+ if not retained:
235
+ return False
236
+ self._folded_blocks[key] = retained
237
+ self._set_blocks_visible(retained, False)
238
+ return True
239
+
240
+ def unfold_all(self) -> None:
241
+ for blocks in tuple(self._folded_blocks.values()):
242
+ self._set_blocks_visible(blocks, True)
243
+ self._folded_blocks.clear()
244
+
245
+ def _set_blocks_visible(
246
+ self,
247
+ blocks: tuple[object, ...],
248
+ visible: bool,
249
+ ) -> None:
250
+ first = None
251
+ last = None
252
+ for block in blocks:
253
+ if not block.isValid():
254
+ continue
255
+ first = block.position() if first is None else first
256
+ last = block.position() + block.length()
257
+ block.setVisible(visible)
258
+ block.setLineCount(1 if visible else 0)
259
+ if first is not None and last is not None:
260
+ self.document().markContentsDirty(first, max(1, last - first))
261
+ self.viewport().update()
262
+ self._update_viewport_margins()
263
+
264
+ def _reset_source_command_state(self) -> None:
265
+ if self._folded_blocks:
266
+ self.unfold_all()
267
+
268
+
269
+ __all__ = [
270
+ "FOLD_BLOCK_LIMIT",
271
+ "FOLD_TOTAL_BLOCK_LIMIT",
272
+ "MAX_FOLDS",
273
+ "SourceCommandMixin",
274
+ ]
@@ -0,0 +1,43 @@
1
+ """Qt-free lexical protection used by the optional syntax highlighter."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ def lexical_protected_spans(
7
+ text: str, language: str
8
+ ) -> tuple[tuple[tuple[int, int], ...], int | None]:
9
+ """Lex strings and the first real line comment without overlap."""
10
+
11
+ if language not in {"python", "c"}:
12
+ raise ValueError("syntax language must be 'python' or 'c'")
13
+ strings: list[tuple[int, int]] = []
14
+ index = 0
15
+ while index < len(text):
16
+ if language == "python" and text[index] == "#":
17
+ return tuple(strings), index
18
+ if language == "c" and text.startswith("//", index):
19
+ return tuple(strings), index
20
+ quote = text[index]
21
+ if quote not in {"'", '"'}:
22
+ index += 1
23
+ continue
24
+ delimiter = quote
25
+ if language == "python" and text.startswith(
26
+ quote * 3, index
27
+ ):
28
+ delimiter = quote * 3
29
+ start = index
30
+ index += len(delimiter)
31
+ while index < len(text):
32
+ if text[index] == "\\":
33
+ index = min(len(text), index + 2)
34
+ continue
35
+ if text.startswith(delimiter, index):
36
+ index += len(delimiter)
37
+ break
38
+ index += 1
39
+ strings.append((start, index))
40
+ return tuple(strings), None
41
+
42
+
43
+ __all__ = ["lexical_protected_spans"]
@@ -0,0 +1,236 @@
1
+ """Line-number and quantum-rail widgets used by the PyCForge editor."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from PyQt5.QtCore import QSize, Qt
6
+ from PyQt5.QtWidgets import QWidget
7
+
8
+
9
+ class _LineNumberArea(QWidget):
10
+ """Narrow line-number gutter delegated to its owning editor."""
11
+
12
+ def __init__(self, editor) -> None:
13
+ super().__init__(editor)
14
+ self.editor = editor
15
+ self.setCursor(Qt.PointingHandCursor)
16
+
17
+ def sizeHint(self): # noqa: N802 - Qt API
18
+ return QSize(self.editor.line_number_area_width(), 0)
19
+
20
+ def paintEvent(self, event) -> None: # noqa: N802 - Qt API
21
+ self.editor.paint_line_number_area(event)
22
+
23
+ def mousePressEvent(self, event) -> None: # noqa: N802 - Qt API
24
+ self.editor.go_to_line_at_y(event.pos().y())
25
+ super().mousePressEvent(event)
26
+
27
+
28
+ class _QuantumRail(QWidget):
29
+ """Accessible bounded overview/navigation rail for editor markers."""
30
+
31
+ WIDTH = 18
32
+
33
+ def __init__(self, editor) -> None:
34
+ super().__init__(editor)
35
+ self.editor = editor
36
+ self._keyboard_marker_index = -1
37
+ self.setObjectName("QuantumVisibilityRail")
38
+ self.setMouseTracking(True)
39
+ self.setCursor(Qt.PointingHandCursor)
40
+ self.setFocusPolicy(Qt.StrongFocus)
41
+ self.setAccessibleName("Source quantum visibility rail")
42
+ self.setAccessibleDescription(
43
+ "Overview of search, diagnostic, mapping, and viewport "
44
+ "positions. Use arrow keys to navigate markers, Page Up "
45
+ "and Page Down to scroll, and Enter or Space to activate "
46
+ "a marker."
47
+ )
48
+ self.setToolTip(
49
+ "Source overview: arrows navigate markers; Enter activates"
50
+ )
51
+
52
+ def sizeHint(self): # noqa: N802 - Qt API
53
+ return QSize(self.WIDTH, 0)
54
+
55
+ def paintEvent(self, event) -> None: # noqa: N802 - Qt API
56
+ self.editor.paint_quantum_rail(event)
57
+
58
+ def mousePressEvent(self, event) -> None: # noqa: N802 - Qt API
59
+ self.setFocus(Qt.MouseFocusReason)
60
+ marker = self.editor.marker_near_rail_position(
61
+ event.pos().y()
62
+ )
63
+ if marker is not None:
64
+ markers = self._ordered_markers()
65
+ self._keyboard_marker_index = markers.index(marker)
66
+ self.editor.activate_rail_position(event.pos().y())
67
+ event.accept()
68
+
69
+ def mouseMoveEvent(self, event) -> None: # noqa: N802 - Qt API
70
+ marker = self.editor.marker_near_rail_position(
71
+ event.pos().y()
72
+ )
73
+ self.setToolTip(
74
+ marker.message
75
+ if marker is not None else "Scroll overview"
76
+ )
77
+ super().mouseMoveEvent(event)
78
+
79
+ def focusInEvent(self, event) -> None: # noqa: N802 - Qt API
80
+ super().focusInEvent(event)
81
+ self.update()
82
+
83
+ def focusOutEvent(self, event) -> None: # noqa: N802 - Qt API
84
+ super().focusOutEvent(event)
85
+ self.update()
86
+
87
+ def keyPressEvent(self, event) -> None: # noqa: N802 - Qt API
88
+ key = event.key()
89
+ if key in (Qt.Key_Up, Qt.Key_Left):
90
+ self._select_relative_marker(-1)
91
+ elif key in (Qt.Key_Down, Qt.Key_Right):
92
+ self._select_relative_marker(1)
93
+ elif key == Qt.Key_Home:
94
+ self._select_endpoint_marker(first=True)
95
+ elif key == Qt.Key_End:
96
+ self._select_endpoint_marker(first=False)
97
+ elif key == Qt.Key_PageUp:
98
+ self._scroll_page(-1)
99
+ elif key == Qt.Key_PageDown:
100
+ self._scroll_page(1)
101
+ elif key in (
102
+ Qt.Key_Return,
103
+ Qt.Key_Enter,
104
+ Qt.Key_Space,
105
+ ):
106
+ self._activate_keyboard_marker()
107
+ else:
108
+ super().keyPressEvent(event)
109
+ return
110
+ event.accept()
111
+
112
+ def markers_changed(self) -> None:
113
+ """Retire a keyboard selection that may no longer exist."""
114
+
115
+ self._keyboard_marker_index = -1
116
+ self.update()
117
+
118
+ def selected_marker(self):
119
+ markers = self._ordered_markers()
120
+ if 0 <= self._keyboard_marker_index < len(markers):
121
+ return markers[self._keyboard_marker_index]
122
+ return None
123
+
124
+ def _ordered_markers(self) -> tuple:
125
+ return tuple(
126
+ sorted(
127
+ self.editor._rail_markers(),
128
+ key=lambda marker: (
129
+ marker.start,
130
+ marker.end,
131
+ marker.kind,
132
+ marker.marker_id,
133
+ ),
134
+ )
135
+ )
136
+
137
+ def _select_relative_marker(self, step: int) -> None:
138
+ markers = self._ordered_markers()
139
+ if not markers:
140
+ self._scroll_line(step)
141
+ return
142
+ if 0 <= self._keyboard_marker_index < len(markers):
143
+ index = min(
144
+ len(markers) - 1,
145
+ max(0, self._keyboard_marker_index + step),
146
+ )
147
+ else:
148
+ position = self.editor.textCursor().position()
149
+ if step > 0:
150
+ index = next(
151
+ (
152
+ marker_index
153
+ for marker_index, marker in enumerate(markers)
154
+ if marker.start >= position
155
+ ),
156
+ len(markers) - 1,
157
+ )
158
+ else:
159
+ index = next(
160
+ (
161
+ marker_index
162
+ for marker_index in range(
163
+ len(markers) - 1, -1, -1
164
+ )
165
+ if markers[marker_index].start <= position
166
+ ),
167
+ 0,
168
+ )
169
+ self._select_marker(index, markers)
170
+
171
+ def _select_endpoint_marker(self, *, first: bool) -> None:
172
+ markers = self._ordered_markers()
173
+ if markers:
174
+ self._select_marker(
175
+ 0 if first else len(markers) - 1,
176
+ markers,
177
+ )
178
+ return
179
+ bar = self.editor.verticalScrollBar()
180
+ bar.setValue(bar.minimum() if first else bar.maximum())
181
+ self.update()
182
+
183
+ def _select_marker(
184
+ self,
185
+ index: int,
186
+ markers: tuple,
187
+ ) -> None:
188
+ self._keyboard_marker_index = index
189
+ marker = markers[index]
190
+ self.editor.go_to_position(
191
+ marker.start, marker.end, focus=False
192
+ )
193
+ self.setToolTip(
194
+ marker.message
195
+ or (
196
+ f"{marker.kind.capitalize()} marker at source "
197
+ f"position {marker.start}"
198
+ )
199
+ )
200
+ self.update()
201
+
202
+ def _activate_keyboard_marker(self) -> None:
203
+ marker = self.selected_marker()
204
+ if marker is None:
205
+ markers = self._ordered_markers()
206
+ if not markers:
207
+ return
208
+ position = self.editor.textCursor().position()
209
+ index = min(
210
+ range(len(markers)),
211
+ key=lambda item: (
212
+ abs(markers[item].start - position),
213
+ item,
214
+ ),
215
+ )
216
+ self._select_marker(index, markers)
217
+ marker = markers[index]
218
+ self.editor.activate_rail_marker(marker, focus=False)
219
+ self.update()
220
+
221
+ def _scroll_line(self, step: int) -> None:
222
+ bar = self.editor.verticalScrollBar()
223
+ bar.setValue(
224
+ bar.value() + step * max(1, bar.singleStep())
225
+ )
226
+ self.update()
227
+
228
+ def _scroll_page(self, step: int) -> None:
229
+ bar = self.editor.verticalScrollBar()
230
+ bar.setValue(
231
+ bar.value() + step * max(1, bar.pageStep())
232
+ )
233
+ self.update()
234
+
235
+
236
+ __all__ = ["_LineNumberArea", "_QuantumRail"]