Coverage for vibefpmgui/code_editor.py: 91%
523 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-11-01 20:51 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-11-01 20:51 +0000
1"""The text editor widget (`CodeEditor`): keys, mouse, selections, indentation, highlights.
3Its gutter, issue squiggles and multiple cursors are mixins:
4`vibefpmgui.editor_gutter`, `vibefpmgui.editor_issues`, `vibefpmgui.multi_cursor`.
5"""
7from __future__ import annotations
9import re
10from bisect import bisect_left
11from collections.abc import Callable
13from qtpy.QtCore import QPoint, QRect, Qt, QTimer, Signal
14from qtpy.QtGui import (
15 QAction,
16 QColor,
17 QCursor,
18 QKeySequence,
19 QPainter,
20 QPalette,
21 QTextCharFormat,
22 QTextCursor,
23 QTextFormat,
24 QTextOption,
25)
26from qtpy.QtWidgets import QMenu, QPlainTextEdit, QTextEdit
28from . import gremlins as gremlins_mod
29from . import lint
30from .completion import CompletionPopup, SignaturePopup
31from .editor_gutter import EditorGutterMixin, LineNumberArea
32from .editor_issues import EditorIssuesMixin
33from .editor_shared import (
34 COVERAGE_COLORS,
35 EXECUTION_COLOR,
36 MARKER_COLORS,
37 MODIFIER_KEYS,
38 backspace_length,
39 occurrence_color,
40 outdent_length,
41 tab_text,
42 worst_severity,
43)
44from .highlighter import document_text
45from .hover_popup import HoverPopup
46from .line_edits import LineEditsMixin
47from .multi_cursor import MultiCursorMixin
49FIND_COLOR = QColor(252, 196, 25, 110) # the find bar's matches
51class CodeEditor(EditorGutterMixin, EditorIssuesMixin, MultiCursorMixin, LineEditsMixin, QPlainTextEdit):
52 """Plain-text editor with a line-number gutter, current-line highlight,
53 auto-indent and soft tabs."""
55 definition_requested = Signal() # ⌘/Ctrl-click on a name (or the right-click menu)
56 implementation_requested = Signal() # right-click menu → Go to Implementation
57 references_requested = Signal() # right-click menu → Find References
58 rename_requested = Signal() # right-click menu → Rename Symbol…
59 lint_fix_requested = Signal(object) # a lint.LintIssue whose fix to apply (hover link or menu)
60 word_added = Signal(str) # "Add to Dictionary" (hover link or menu)
61 explain_requested = Signal(int) # right-click → Explain Error with AI, on this line (1-based)
62 edit_selection_requested = Signal() # right-click → Edit Selection with AI
63 typed = Signal(str) # a key that changed the text (after it did; "\b" = Backspace)
64 completion_chosen = Signal(object) # Enter/Tab in the completion list
65 cursors_changed = Signal() # extra cursors added or removed (multiple cursors)
66 breakpoints_changed = Signal() # a breakpoint added or removed (a click in the gutter, F9)
68 indent_width = 4 # View → Indentation (set by the window)
69 use_tabs = False
70 highlight_matches = True # View → Highlight Other Occurrences of the Selection
71 show_coverage = True # View → Show Coverage
72 MAX_MATCHES = 2000 # (highlighted at most)
74 def __init__(self, parent=None):
75 super().__init__(parent)
76 # Set while a language server is attached: called with (line, column,
77 # global position, lint tooltip HTML) when the mouse rests on a name.
78 self.hover_provider: Callable | None = None
79 self._hover_word: tuple[int, int] | None = None
80 self.hover_popup = HoverPopup(self)
81 self.completion_popup = CompletionPopup(self)
82 self.signature_popup = SignaturePopup(self)
83 self.viewport().setMouseTracking(True)
84 for bar in (self.verticalScrollBar(), self.horizontalScrollBar()):
85 bar.valueChanged.connect(self.hide_popups)
86 self.markers: dict[int, list[tuple[str, str]]] = {} # line -> [(severity, message)]
87 self.change_marks: dict[int, str] = {} # line -> "unsaved" / "saved"
88 self.deletions: dict[int, tuple[str, int]] = {} # line -> (kind, lines deleted above it)
89 # Breakpoints (see editor_gutter): cursors at their lines' starts, so they move with edits.
90 self.debuggable = False # a source file the debugger can stop in: a breakpoint column
91 self._breakpoints: list[tuple[QTextCursor, bool]] = [] # (at its line, enabled)
92 self.breakpoint_problems: dict[int, str] = {} # line -> why the debugger couldn't place it
93 self.execution_line: int | None = None # where the program is stopped (an arrow, the line tinted)
94 self.commit_lines: tuple[set[int], QColor] | None = None # a git blame card hovered: its lines, its color
95 self.minimap = None # the vibefpmgui.minimap.Minimap beside it, if any
96 self.find_selections: list[tuple[int, int]] = [] # the find bar's matches: (start, end) positions
97 self.coverage: dict[int, int] = {} # line -> times the tests ran it (see set_coverage)
98 self.coverage_branches: dict[int, tuple[int, int]] = {} # line -> (branches taken, branches)
99 # The selection's other occurrences, highlighted (see update_occurrences):
100 # Match case / Whole words come from the tab's find bar.
101 self.occurrence_selections: list[tuple[int, int]] = [] # (start, end) positions
102 self._occurrence_color = QColor()
103 self.match_options: Callable[[], tuple[bool, bool]] = lambda: (False, False)
104 self._occurrence_timer = QTimer(self)
105 self._occurrence_timer.setSingleShot(True)
106 self._occurrence_timer.setInterval(120) # (once the selection rests: not on every step of a drag)
107 self._occurrence_timer.timeout.connect(self.update_occurrences)
108 self.selectionChanged.connect(self._occurrence_timer.start)
109 self.lint_issues: list[lint.LintIssue] = [] # underlined, with a tooltip
110 self._spelling: list = [] # the misspelled words among them, in line order (see set_lint)
111 self._spelling_lines: list[int] = []
112 # The highlights that only change when the markers, issues or gremlins
113 # do, for the lines around the view (see update_extra_selections).
114 self._fixed_selections: list[QTextEdit.ExtraSelection] = []
115 self._fixed_key: tuple | None = None # (what they were made for: the data's version, the lines)
116 self._highlights_version = 0
117 self._issue_lines: dict[int, list] = {} # line -> its underlined issues (not spelling)
118 self.gremlins: dict[int, list[gremlins_mod.Gremlin]] = {} # line -> its gremlins (View → Show Gremlins)
119 # Multiple cursors: ⌥/Alt-click adds one, ⌘⇧/Ctrl+Shift-click a column
120 # of them. Typing, deleting and moving happen at all of them; these
121 # QTextCursors stay in place as the text changes.
122 self.extra_cursors: list[QTextCursor] = []
123 # With several cursors they're all drawn here (Qt's own is hidden), so
124 # they look alike and blink together, at the system's rate.
125 self._caret_width = self.cursorWidth() # Qt's (restored with one cursor)
126 self._caret_on = True
127 self._caret_timer = QTimer(self)
128 self._caret_timer.timeout.connect(self._blink)
129 self.cursorPositionChanged.connect(self._restart_blink)
130 self.textChanged.connect(self._restart_blink)
131 self.gutter = LineNumberArea(self)
132 self.setLineWrapMode(QPlainTextEdit.NoWrap)
133 self.blockCountChanged.connect(self.update_gutter_width)
134 self.updateRequest.connect(self._update_gutter)
135 self.cursorPositionChanged.connect(lambda: self.update_extra_selections(rebuild=False))
136 self.verticalScrollBar().valueChanged.connect(self._scrolled)
137 self.update_gutter_width()
138 self.update_extra_selections()
140 def toPlainText(self) -> str:
141 """The text exactly as it is (see highlighter.document_text): what's
142 saved, linted, searched and sent to the language server."""
143 return document_text(self.document())
145 def resizeEvent(self, event):
146 super().resizeEvent(event)
147 cr = self.contentsRect()
148 self.gutter.setGeometry(QRect(cr.left(), cr.top(), self.gutter_width(), cr.height()))
149 if self.lineWrapMode() != QPlainTextEdit.NoWrap: # (lines re-wrap: highlight their new rows)
150 QTimer.singleShot(0, self, self.update_extra_selections)
151 else:
152 self._scrolled() # (more or fewer lines on view)
154 def set_word_wrap(self, on: bool):
155 """Wrap long lines at the window's edge (at a space if there is
156 one), or scroll sideways."""
157 self.setWordWrapMode(QTextOption.WrapAtWordBoundaryOrAnywhere)
158 self.setLineWrapMode(QPlainTextEdit.WidgetWidth if on else QPlainTextEdit.NoWrap)
159 QTimer.singleShot(0, self, self.update_extra_selections)
160 self.gutter.update()
161 self._update_minimap()
163 def _update_minimap(self):
164 if self.minimap is not None:
165 self.minimap.update()
167 def wheelEvent(self, event):
168 self.hide_popups()
169 super().wheelEvent(event)
171 def focusOutEvent(self, event):
172 if not self.hover_popup.geometry().contains(QCursor.pos()):
173 self.hover_popup.hide()
174 if not self.completion_popup.geometry().contains(QCursor.pos()):
175 self.completion_popup.hide() # (unless it's being double-clicked)
176 self.signature_popup.hide()
177 super().focusOutEvent(event)
178 if self.extra_cursors:
179 self._caret_timer.stop()
180 self.viewport().update() # (no cursors without the focus, like Qt's)
182 def hideEvent(self, event):
183 self.hide_popups()
184 super().hideEvent(event)
186 def mousePressEvent(self, event):
187 mods = event.modifiers()
188 if event.button() == Qt.LeftButton: 188 ↛ 198line 188 didn't jump to line 198 because the condition on line 188 was always true
189 point = event.position().toPoint()
190 if mods & Qt.AltModifier and not mods & (Qt.ControlModifier | Qt.ShiftModifier):
191 self.toggle_cursor_at(self.cursorForPosition(point).position()) # ⌥/Alt-click
192 return
193 if mods & Qt.ControlModifier and mods & Qt.ShiftModifier: # ⌘⇧/Ctrl+Shift-click
194 clicked = self.cursorForPosition(point)
195 self.add_column_cursors(clicked.blockNumber(), clicked.positionInBlock())
196 return
197 self.clear_extra_cursors() # (any other click: back to one cursor)
198 if (event.button() == Qt.LeftButton and event.modifiers() & Qt.ControlModifier 198 ↛ 201line 198 didn't jump to line 201 because the condition on line 198 was never true
199 and self.hover_provider is not None):
200 # ⌘-click (Ctrl-click on Linux/Windows): go to the definition.
201 self.setTextCursor(self.cursorForPosition(event.position().toPoint()))
202 self.definition_requested.emit()
203 return
204 super().mousePressEvent(event)
206 def contextMenuEvent(self, event):
207 menu = self.context_menu(event.pos(), from_mouse=event.reason() == event.Reason.Mouse)
208 menu.exec(event.globalPos())
209 menu.deleteLater()
211 def context_menu(self, pos: QPoint, from_mouse: bool = True) -> QMenu:
212 """The usual Cut/Copy/Paste menu, plus fortitude's fixes for the
213 issues at that spot and the language server's commands for the name
214 there (when one is attached). ``pos``: where the menu was asked for
215 (viewport coordinates); ``from_mouse`` False: from the keyboard, for
216 the text cursor's spot."""
217 menu = self.createStandardContextMenu(pos)
218 # fortitude's fixes for the issues at that spot.
219 spot = self.cursorForPosition(pos).position() if from_mouse else self.textCursor().position()
220 fixes = [i for i in self.lint_issues if i.fixable
221 and (span := self._lint_span(i)) and span[0] <= spot <= span[1]]
222 misspelled = next((i for i in self.lint_issues if i.severity == "spelling"
223 and (span := self._lint_span(i)) and span[0] <= spot <= span[1]), None)
224 if misspelled is not None:
225 first = menu.actions()[0] if menu.actions() else None
226 words = misspelled.suggestions()
227 for word in words:
228 action = QAction(word, menu)
229 font = action.font()
230 font.setBold(True)
231 action.setFont(font)
232 action.triggered.connect(lambda _=False, i=misspelled, w=word: self.replace_word(i, w))
233 menu.insertAction(first, action)
234 if not words: 234 ↛ 235line 234 didn't jump to line 235 because the condition on line 234 was never true
235 none = QAction("(No Spelling Suggestions)", menu)
236 none.setEnabled(False)
237 menu.insertAction(first, none)
238 add = QAction(f"Add “{misspelled.word}” to Dictionary", menu)
239 add.triggered.connect(lambda _=False, w=misspelled.word: self.word_added.emit(w))
240 menu.insertAction(first, add)
241 menu.insertSeparator(first)
242 if self.textCursor().hasSelection():
243 first = menu.actions()[0] if menu.actions() else None
244 edit = QAction("Edit Selection with AI…", menu)
245 edit.setToolTip("Ask the local AI model to change the selected lines; you accept or reject each edit")
246 edit.triggered.connect(self.edit_selection_requested.emit)
247 menu.insertAction(first, edit)
248 menu.insertSeparator(first)
249 line = self.document().findBlock(spot).blockNumber() + 1
250 if any(severity not in ("review", "remark") for severity, _ in self.markers.get(line, [])):
251 first = menu.actions()[0] if menu.actions() else None
252 explain = QAction("Explain Error with AI", menu)
253 explain.setToolTip("Ask the local AI model what this line's problem means, and how to fix it")
254 explain.triggered.connect(lambda _=False, n=line: self.explain_requested.emit(n))
255 menu.insertAction(first, explain)
256 menu.insertSeparator(first)
257 if fixes:
258 first = menu.actions()[0] if menu.actions() else None
259 for issue in fixes:
260 label = f"Fix: {issue.fix or issue.message}" + ("" if issue.fix_safe else " (unsafe — check it)")
261 action = QAction(label, menu)
262 action.triggered.connect(lambda _=False, i=issue: self.lint_fix_requested.emit(i))
263 menu.insertAction(first, action)
264 menu.insertSeparator(first)
265 if self.hover_provider is not None:
266 on_name = False
267 if from_mouse: 267 ↛ 276line 267 didn't jump to line 276 because the condition on line 267 was always true
268 point = pos # (already in viewport coordinates)
269 on_name = self.word_at(point) is not None
270 if on_name: # the commands act on the name at the cursor
271 cursor = self.cursorForPosition(point)
272 if not (self.textCursor().selectionStart() <= cursor.position() 272 ↛ 278line 272 didn't jump to line 278 because the condition on line 272 was always true
273 <= self.textCursor().selectionEnd() and self.textCursor().hasSelection()):
274 self.setTextCursor(cursor)
275 else: # opened from the keyboard: the name at the cursor
276 rect = self.cursorRect()
277 on_name = self.word_at(rect.center()) is not None
278 first = menu.actions()[0] if menu.actions() else None
279 for text, keys, signal in (("Go to Definition", "F12", self.definition_requested),
280 ("Go to Implementation", "Ctrl+F12", self.implementation_requested),
281 ("Find References", "Shift+F12", self.references_requested),
282 ("Rename Symbol…", "F2", self.rename_requested)):
283 action = QAction(text, menu)
284 action.setShortcut(QKeySequence(keys)) # shown in the menu; the Edit menu's work
285 action.setShortcutContext(Qt.WidgetShortcut)
286 action.setEnabled(on_name)
287 action.triggered.connect(signal.emit)
288 menu.insertAction(first, action)
289 menu.insertSeparator(first)
290 return menu
292 def mouseMoveEvent(self, event):
293 super().mouseMoveEvent(event)
294 if self.hover_popup.isVisible():
295 point = event.position().toPoint()
296 anchor = self.hover_popup.anchor
297 on_line = (isinstance(anchor, tuple) and anchor[:1] == ("line",)
298 and self.cursorForPosition(point).blockNumber() + 1 == anchor[1])
299 if on_line or self.hover_key(point) == anchor:
300 self.hover_popup.keep()
301 else:
302 self.hover_popup.schedule_hide()
303 if self.hover_provider is not None and event.modifiers() & Qt.ControlModifier \
304 and self.word_at(event.position().toPoint()):
305 self.viewport().setCursor(Qt.PointingHandCursor)
306 else:
307 self.viewport().setCursor(Qt.IBeamCursor)
309 def go_to_position(self, line: int, column: int = 0):
310 """Put the cursor at a line (1-based) and column (0-based)."""
311 self.go_to_line(line)
312 cursor = self.textCursor()
313 cursor.setPosition(cursor.block().position() + min(column, max(0, cursor.block().length() - 1)))
314 self.setTextCursor(cursor)
315 self.centerCursor()
317 # -------------------------------------------------------- highlights
318 def update_extra_selections(self, rebuild: bool = True):
319 """Current line, lines with errors/warnings, underlined issues,
320 gremlins, the extra cursors' selections, the selection's other
321 occurrences and find matches.
323 Only the lines around the view get highlights (see `_line_window`):
324 each is a QTextCursor that Qt moves along at every edit and goes
325 through at every cursor move, and a big file's thousands of issues
326 made typing, and replacing its text, take seconds. Scrolling makes
327 those of the lines coming into view. ``rebuild``: the markers,
328 issues or gremlins changed (or the colors, or the line wrapping).
330 Not while the text is being replaced (``setPlainText``, as a file
331 changed on disk is reloaded): emptying the document can narrow the
332 gutter and so scroll, calling this with no valid line anywhere, and a
333 cursor made from one crashed Qt. It's called again once the text is in."""
334 if not self.textCursor().block().isValid():
335 return
336 if rebuild:
337 self._highlights_version += 1
338 self._issue_lines = {}
339 for issue in self.lint_issues:
340 if issue.severity != "spelling": # (drawn by paintEvent: see _paint_squiggles)
341 self._issue_lines.setdefault(issue.line, []).append(issue)
342 window = self._line_window()
343 key = (self._highlights_version, window)
344 if key != self._fixed_key:
345 self._fixed_selections = self._marker_and_issue_selections(*window)
346 self._fixed_key = key
347 selections = list(self._fixed_selections)
348 if not self.isReadOnly(): 348 ↛ 352line 348 didn't jump to line 352 because the condition on line 348 was always true
349 color = QColor(self.palette().color(QPalette.Highlight))
350 color.setAlpha(28)
351 selections += self._line_highlight(self.textCursor().block(), color)
352 if self.execution_line: # (where the debugger stopped)
353 block = self.document().findBlockByNumber(self.execution_line - 1)
354 if block.isValid(): 354 ↛ 358line 354 didn't jump to line 358 because the condition on line 354 was always true
355 tint = QColor(EXECUTION_COLOR)
356 tint.setAlpha(70)
357 selections += self._line_highlight(block, tint)
358 if self.commit_lines is not None: # (a git blame card under the mouse: its commit's lines)
359 lines, tint = self.commit_lines
360 first, last = window
361 block = self.document().findBlockByNumber(first)
362 while block.isValid() and block.blockNumber() <= last:
363 if block.blockNumber() in lines:
364 selections += self._line_highlight(block, tint)
365 block = block.next()
366 selected = QColor(self.palette().color(QPalette.Highlight))
367 for c in self.extra_cursors:
368 if c.hasSelection():
369 sel = QTextEdit.ExtraSelection()
370 sel.format.setBackground(selected)
371 sel.format.setForeground(self.palette().color(QPalette.HighlightedText))
372 sel.cursor = QTextCursor(c)
373 selections.append(sel)
374 selections += self._span_selections(self.occurrence_selections, self._occurrence_color, window)
375 selections += self._span_selections(self.find_selections, FIND_COLOR, window)
376 self.setExtraSelections(selections)
378 def show_commit_lines(self, lines: set[int] | None, color: QColor | None = None):
379 """Tint these lines (0-based; None: none) in ``color``: a git blame
380 card's commit, while the mouse is on its card."""
381 self.commit_lines = (lines, color) if lines else None
382 self.update_extra_selections(rebuild=False)
384 def _line_window(self) -> tuple[int, int]:
385 """The lines (0-based, first and last) to highlight: those on view, and
386 a screenful either side, in steps of a screenful (scrolling a line
387 doesn't make them all again)."""
388 rows = max(8, self.viewport().height() // max(1, self.fontMetrics().lineSpacing()))
389 first = self.firstVisibleBlock().blockNumber() // rows * rows
390 return max(0, first - rows), first + 3 * rows
392 def _scrolled(self, *_):
393 if self._fixed_key is not None and self._fixed_key[1] != self._line_window():
394 self.update_extra_selections(rebuild=False)
396 def _span_selections(self, spans: list[tuple[int, int]], color: QColor, window: tuple[int, int]) -> list:
397 """Highlights for the (start, end) positions, in order, that are in the window's lines."""
398 if not spans:
399 return []
400 doc = self.document()
401 low = doc.findBlockByNumber(window[0]).position()
402 last = doc.findBlockByNumber(window[1])
403 high = last.position() + last.length() if last.isValid() else doc.characterCount()
404 out = []
405 for start, end in spans[bisect_left(spans, (low, 0)):]:
406 if start > high: 406 ↛ 407line 406 didn't jump to line 407 because the condition on line 406 was never true
407 break
408 sel = QTextEdit.ExtraSelection()
409 sel.format.setBackground(color)
410 sel.cursor = QTextCursor(doc)
411 sel.cursor.setPosition(start)
412 sel.cursor.setPosition(end, QTextCursor.KeepAnchor)
413 out.append(sel)
414 return out
416 def _marker_and_issue_selections(self, first: int, last: int) -> list:
417 """The highlights of lines ``first`` to ``last`` (0-based): the tests'
418 missed lines, then the markers' tints, the issues' squiggles and the
419 gremlins' tints (each drawn over the one before)."""
420 missed = QColor(COVERAGE_COLORS["missed"])
421 missed.setAlpha(26)
422 tint = QColor(gremlins_mod.COLOR)
423 tint.setAlpha(70)
424 coverage, markers, issues, gremlins = [], [], [], []
425 block = self.document().findBlockByNumber(first)
426 while block.isValid() and block.blockNumber() <= last:
427 line = block.blockNumber() + 1
428 if self.show_coverage and self.coverage.get(line) == 0:
429 coverage += self._line_highlight(block, missed)
430 notes = self.markers.get(line)
431 if notes:
432 color = QColor(MARKER_COLORS.get(worst_severity(notes), "#868e96"))
433 color.setAlpha(34)
434 markers += self._line_highlight(block, color)
435 for issue in self._issue_lines.get(line, ()):
436 span = self._lint_span(issue)
437 if span is None: 437 ↛ 438line 437 didn't jump to line 438 because the condition on line 437 was never true
438 continue
439 sel = QTextEdit.ExtraSelection()
440 sel.format.setUnderlineStyle(QTextCharFormat.WaveUnderline)
441 sel.format.setUnderlineColor(QColor(MARKER_COLORS[issue.severity]))
442 sel.cursor = QTextCursor(self.document())
443 sel.cursor.setPosition(span[0])
444 sel.cursor.setPosition(span[1], QTextCursor.KeepAnchor)
445 issues.append(sel)
446 for g in self.gremlins.get(line, ()): # (a tint: an invisible one would show nothing else)
447 if g.column >= block.length() - 1:
448 continue
449 sel = QTextEdit.ExtraSelection()
450 sel.format.setBackground(tint)
451 sel.cursor = QTextCursor(block)
452 sel.cursor.setPosition(block.position() + g.column)
453 sel.cursor.setPosition(block.position() + g.column + 1, QTextCursor.KeepAnchor)
454 gremlins.append(sel)
455 block = block.next()
456 return coverage + markers + issues + gremlins
458 @staticmethod
459 def _line_highlight(block, color: QColor) -> list:
460 """A full-width background for a line: one per row it is shown in
461 (with View → Word Wrap a long line takes several, and Qt fills only
462 the row the selection's cursor is in). Nothing for a block not in a
463 document: a cursor made from it crashes Qt."""
464 if not block.isValid(): 464 ↛ 465line 464 didn't jump to line 465 because the condition on line 464 was never true
465 return []
466 layout = block.layout()
467 starts = [layout.lineAt(i).textStart() for i in range(layout.lineCount())] if layout else []
468 out = []
469 for start in starts or [0]:
470 sel = QTextEdit.ExtraSelection()
471 sel.format.setBackground(color)
472 sel.format.setProperty(QTextFormat.FullWidthSelection, True)
473 sel.cursor = QTextCursor(block)
474 sel.cursor.setPosition(block.position() + start)
475 out.append(sel)
476 return out
478 def update_occurrences(self):
479 """Highlight the other occurrences of the selected text in the file
480 (single-line selections of 2 to 200 characters; Match case and Whole
481 words as set in the find bar)."""
482 found: list[tuple[int, int]] = []
483 cursor = self.textCursor()
484 selected = cursor.selectedText()
485 if (self.highlight_matches and len(self.extra_cursors) == 0 and 2 <= len(selected) <= 200
486 and selected.strip() and "\u2029" not in selected):
487 case, word = self.match_options()
488 pattern = re.escape(selected)
489 if word: # (as the find bar: "_" is part of a word, so count doesn't match in count_all)
490 pattern = rf"(?<!\w){pattern}(?!\w)"
491 text = self.toPlainText()
492 simple = text.isascii() # (else Qt counts characters beyond U+FFFF twice)
493 self._occurrence_color = occurrence_color(self.palette().color(QPalette.Base))
494 mine = cursor.selectionStart()
495 for match in re.finditer(pattern, text, 0 if case else re.IGNORECASE):
496 start, end = match.span()
497 if not simple: 497 ↛ 498line 497 didn't jump to line 498 because the condition on line 497 was never true
498 start, end = self._qt_position(text, start), self._qt_position(text, end)
499 if start == mine:
500 continue # (the selection itself)
501 found.append((start, end))
502 if len(found) >= self.MAX_MATCHES: 502 ↛ 503line 502 didn't jump to line 503 because the condition on line 502 was never true
503 break
504 if found or self.occurrence_selections:
505 self.occurrence_selections = found
506 self.update_extra_selections(rebuild=False)
508 def set_find_selections(self, spans: list[tuple[int, int]]):
509 """The find bar's matches: (start, end) positions, in order."""
510 self.find_selections = spans
511 self.update_extra_selections(rebuild=False) # (kept apart: see update_extra_selections)
512 self._update_minimap()
514 def paintEvent(self, event):
515 super().paintEvent(event)
516 self._paint_squiggles(event.rect())
517 if self.extra_cursors and self._caret_on and self.hasFocus(): # (Qt's own is hidden: see _caret_width)
518 painter = QPainter(self.viewport())
519 color = self.palette().color(QPalette.Text)
520 for c in (self.textCursor(), *self.extra_cursors):
521 rect = self.cursorRect(c)
522 painter.fillRect(QRect(rect.left(), rect.top(), self._caret_width, rect.height()), color)
523 painter.end()
525 def focusInEvent(self, event):
526 super().focusInEvent(event)
527 self._restart_blink()
529 def keyPressEvent(self, event):
530 key = event.key()
531 if key in MODIFIER_KEYS:
532 # ⌘/⇧/⌥/⌃ on their own (e.g. to ⌘-click, or for a screenshot):
533 # keep the hover popup open.
534 super().keyPressEvent(event)
535 return
536 self.hover_popup.hide()
537 if self.extra_cursors and self._multi_cursor_key(event):
538 return
539 popup = self.completion_popup
540 plain = not event.modifiers() & (Qt.ShiftModifier | Qt.ControlModifier | Qt.AltModifier | Qt.MetaModifier)
541 if popup.isVisible():
542 steps = {Qt.Key_Up: -1, Qt.Key_Down: 1,
543 Qt.Key_PageUp: -popup.page_rows(), Qt.Key_PageDown: popup.page_rows()}
544 if key in steps and plain:
545 popup.move_selection(steps[key])
546 return
547 if key in (Qt.Key_Return, Qt.Key_Enter, Qt.Key_Tab) and plain: 547 ↛ 550line 547 didn't jump to line 550 because the condition on line 547 was always true
548 self.completion_chosen.emit(popup.current())
549 return
550 if key == Qt.Key_Escape:
551 popup.hide()
552 return
553 if key == Qt.Key_Escape and self.signature_popup.isVisible(): 553 ↛ 554line 553 didn't jump to line 554 because the condition on line 553 was never true
554 self.signature_popup.hide()
555 return
556 if key in (Qt.Key_Up, Qt.Key_Down) and event.modifiers() == Qt.AltModifier: 556 ↛ 559line 556 didn't jump to line 559 because the condition on line 556 was never true
557 # ⌥↑ / ⌥↓ (Alt+↑ / ↓): Move Line Up / Down. (Here too, not only as the
558 # menu's shortcut: on macOS the text field takes these keys for itself.)
559 self.move_lines(down=key == Qt.Key_Down)
560 return
561 before = self.document().revision()
562 self._edit_key(event)
563 if self.document().revision() != before: 563 ↛ exitline 563 didn't return from function 'keyPressEvent' because the condition on line 563 was always true
564 self.typed.emit("\b" if key == Qt.Key_Backspace else event.text())
566 def _edit_key(self, event):
567 key = event.key()
568 if key == Qt.Key_Tab and not event.modifiers() & (Qt.ControlModifier | Qt.AltModifier):
569 cursor = self.textCursor()
570 if cursor.hasSelection() and "\u2029" in cursor.selectedText(): # (several lines: indent them)
571 self._indent_selection(+1)
572 else:
573 cursor.beginEditBlock()
574 cursor.removeSelectedText()
575 cursor.insertText(self.tab_text(cursor))
576 cursor.endEditBlock()
577 self.setTextCursor(cursor)
578 return
579 if key == Qt.Key_Backtab:
580 self._indent_selection(-1)
581 return
582 if key in (Qt.Key_Return, Qt.Key_Enter) and not event.modifiers() & Qt.ShiftModifier:
583 # Keep the current line's indentation.
584 line = self.textCursor().block().text()
585 indent = line[:len(line) - len(line.lstrip())]
586 cursor = self.textCursor()
587 cursor.beginEditBlock() # (one Undo step, the line break and its indentation)
588 super().keyPressEvent(event)
589 self.textCursor().insertText(indent)
590 cursor.endEditBlock()
591 return
592 if key == Qt.Key_Backspace and not self.textCursor().hasSelection() and not self.use_tabs: 592 ↛ 601line 592 didn't jump to line 601 because the condition on line 592 was always true
593 # Backspace in leading spaces goes back to the previous tab stop.
594 cursor = self.textCursor()
595 count = backspace_length(cursor.block().text()[:cursor.positionInBlock()], self.indent_width)
596 if count: 596 ↛ 601line 596 didn't jump to line 601 because the condition on line 596 was always true
597 cursor.movePosition(QTextCursor.Left, QTextCursor.KeepAnchor, count)
598 cursor.removeSelectedText() # (one Undo step)
599 self.setTextCursor(cursor)
600 return
601 super().keyPressEvent(event)
603 def tab_text(self, cursor: QTextCursor) -> str:
604 """What Tab types at a cursor (see `tab_text`)."""
605 return tab_text(cursor.block().text()[:cursor.positionInBlock()], self.indent_width, self.use_tabs)
607 def indent_unit(self) -> str:
608 """One indent level: a tab, or ``indent_width`` spaces."""
609 return "\t" if self.use_tabs else " " * self.indent_width
611 def set_indentation(self, width: int, use_tabs: bool):
612 """View → Indentation (tabs are shown ``width`` columns wide too)."""
613 self.indent_width, self.use_tabs = width, use_tabs
614 self.setTabStopDistance(self.fontMetrics().horizontalAdvance(" ") * width)
616 def _indent_selection(self, direction: int):
617 """Indent (Tab) or outdent (⇧Tab) the selected lines, or the cursor's;
618 a selection ending at the start of a line leaves that line alone."""
619 cursor = self.textCursor()
620 start = self.document().findBlock(cursor.selectionStart())
621 end = self.document().findBlock(cursor.selectionEnd())
622 if end != start and cursor.selectionEnd() == end.position():
623 end = end.previous()
624 cursor.beginEditBlock()
625 block = start
626 while block.isValid(): 626 ↛ 638line 626 didn't jump to line 638 because the condition on line 626 was always true
627 c = QTextCursor(block)
628 if direction > 0:
629 if block.text().strip() or block == start == end: # (blank lines stay blank) 629 ↛ 635line 629 didn't jump to line 635 because the condition on line 629 was always true
630 c.insertText(self.indent_unit())
631 else:
632 c.movePosition(QTextCursor.Right, QTextCursor.KeepAnchor,
633 outdent_length(block.text(), self.indent_width))
634 c.removeSelectedText()
635 if block == end:
636 break
637 block = block.next()
638 cursor.endEditBlock()
640 def _qt_position(self, text: str, offset: int) -> int:
641 """A position in the document for an offset in toPlainText() (Qt
642 counts characters beyond U+FFFF twice)."""
643 return len(text[:offset].encode("utf-16-le")) // 2
645 def replace_ranges(self, ranges: list[tuple[int, int, str]]):
646 """Replace (start, end, new text) ranges, given as offsets in
647 toPlainText(), as one step to undo."""
648 text = self.toPlainText()
649 cursor = QTextCursor(self.document())
650 cursor.beginEditBlock()
651 for start, end, new in sorted(ranges, key=lambda r: r[0], reverse=True): # (from the end: offsets stay right)
652 cursor.setPosition(self._qt_position(text, start))
653 cursor.setPosition(self._qt_position(text, end), QTextCursor.KeepAnchor)
654 cursor.insertText(new)
655 cursor.endEditBlock()
657 def select_range(self, start: int, end: int):
658 """Select (and show) a range given as offsets in toPlainText()."""
659 text = self.toPlainText()
660 cursor = QTextCursor(self.document())
661 cursor.setPosition(self._qt_position(text, end))
662 cursor.setPosition(self._qt_position(text, start), QTextCursor.KeepAnchor)
663 self.setTextCursor(cursor)
664 self.centerCursor()
666 def selected_lines(self) -> tuple[int, int] | None:
667 """The first and last lines of the selection (1-based), if any; a
668 selection ending at the start of a line doesn't include that line."""
669 cursor = self.textCursor()
670 if not cursor.hasSelection():
671 return None
672 doc = self.document()
673 first = doc.findBlock(cursor.selectionStart()).blockNumber() + 1
674 end_block = doc.findBlock(cursor.selectionEnd())
675 last = end_block.blockNumber() + (0 if cursor.selectionEnd() == end_block.position() else 1)
676 return first, max(first, last)
678 def go_to_line(self, line: int, select: bool = False):
679 block = self.document().findBlockByNumber(max(0, line - 1))
680 if not block.isValid(): 680 ↛ 681line 680 didn't jump to line 681 because the condition on line 680 was never true
681 block = self.document().lastBlock()
682 cursor = QTextCursor(block)
683 if select:
684 cursor.movePosition(QTextCursor.EndOfBlock, QTextCursor.KeepAnchor)
685 self.setTextCursor(cursor)
686 self.centerCursor()
687 self.setFocus()