vibefpmgui.code_editor

The text editor widget (CodeEditor): keys, mouse, selections, indentation, highlights.

Its gutter, issue squiggles and multiple cursors are mixins: vibefpmgui.editor_gutter, vibefpmgui.editor_issues, vibefpmgui.multi_cursor.

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

Plain-text editor with a line-number gutter, current-line highlight, auto-indent and soft tabs.

CodeEditor(parent=None)
 75    def __init__(self, parent=None):
 76        super().__init__(parent)
 77        # Set while a language server is attached: called with (line, column,
 78        # global position, lint tooltip HTML) when the mouse rests on a name.
 79        self.hover_provider: Callable | None = None
 80        self._hover_word: tuple[int, int] | None = None
 81        self.hover_popup = HoverPopup(self)
 82        self.completion_popup = CompletionPopup(self)
 83        self.signature_popup = SignaturePopup(self)
 84        self.viewport().setMouseTracking(True)
 85        for bar in (self.verticalScrollBar(), self.horizontalScrollBar()):
 86            bar.valueChanged.connect(self.hide_popups)
 87        self.markers: dict[int, list[tuple[str, str]]] = {}  # line -> [(severity, message)]
 88        self.change_marks: dict[int, str] = {}                  # line -> "unsaved" / "saved"
 89        self.deletions: dict[int, tuple[str, int]] = {}          # line -> (kind, lines deleted above it)
 90        # Breakpoints (see editor_gutter): cursors at their lines' starts, so they move with edits.
 91        self.debuggable = False  # a source file the debugger can stop in: a breakpoint column
 92        self._breakpoints: list[tuple[QTextCursor, bool]] = []  # (at its line, enabled)
 93        self.breakpoint_problems: dict[int, str] = {}  # line -> why the debugger couldn't place it
 94        self.execution_line: int | None = None  # where the program is stopped (an arrow, the line tinted)
 95        self.commit_lines: tuple[set[int], QColor] | None = None  # a git blame card hovered: its lines, its color
 96        self.minimap = None  # the vibefpmgui.minimap.Minimap beside it, if any
 97        self.find_selections: list[tuple[int, int]] = []  # the find bar's matches: (start, end) positions
 98        self.coverage: dict[int, int] = {}  # line -> times the tests ran it (see set_coverage)
 99        self.coverage_branches: dict[int, tuple[int, int]] = {}  # line -> (branches taken, branches)
100        # The selection's other occurrences, highlighted (see update_occurrences):
101        # Match case / Whole words come from the tab's find bar.
102        self.occurrence_selections: list[tuple[int, int]] = []  # (start, end) positions
103        self._occurrence_color = QColor()
104        self.match_options: Callable[[], tuple[bool, bool]] = lambda: (False, False)
105        self._occurrence_timer = QTimer(self)
106        self._occurrence_timer.setSingleShot(True)
107        self._occurrence_timer.setInterval(120)  # (once the selection rests: not on every step of a drag)
108        self._occurrence_timer.timeout.connect(self.update_occurrences)
109        self.selectionChanged.connect(self._occurrence_timer.start)
110        self.lint_issues: list[lint.LintIssue] = []  # underlined, with a tooltip
111        self._spelling: list = []        # the misspelled words among them, in line order (see set_lint)
112        self._spelling_lines: list[int] = []
113        # The highlights that only change when the markers, issues or gremlins
114        # do, for the lines around the view (see update_extra_selections).
115        self._fixed_selections: list[QTextEdit.ExtraSelection] = []
116        self._fixed_key: tuple | None = None   # (what they were made for: the data's version, the lines)
117        self._highlights_version = 0
118        self._issue_lines: dict[int, list] = {}  # line -> its underlined issues (not spelling)
119        self.gremlins: dict[int, list[gremlins_mod.Gremlin]] = {}  # line -> its gremlins (View → Show Gremlins)
120        # Multiple cursors: ⌥/Alt-click adds one, ⌘⇧/Ctrl+Shift-click a column
121        # of them. Typing, deleting and moving happen at all of them; these
122        # QTextCursors stay in place as the text changes.
123        self.extra_cursors: list[QTextCursor] = []
124        # With several cursors they're all drawn here (Qt's own is hidden), so
125        # they look alike and blink together, at the system's rate.
126        self._caret_width = self.cursorWidth()  # Qt's (restored with one cursor)
127        self._caret_on = True
128        self._caret_timer = QTimer(self)
129        self._caret_timer.timeout.connect(self._blink)
130        self.cursorPositionChanged.connect(self._restart_blink)
131        self.textChanged.connect(self._restart_blink)
132        self.gutter = LineNumberArea(self)
133        self.setLineWrapMode(QPlainTextEdit.NoWrap)
134        self.blockCountChanged.connect(self.update_gutter_width)
135        self.updateRequest.connect(self._update_gutter)
136        self.cursorPositionChanged.connect(lambda: self.update_extra_selections(rebuild=False))
137        self.verticalScrollBar().valueChanged.connect(self._scrolled)
138        self.update_gutter_width()
139        self.update_extra_selections()
def definition_requested(unknown):

The type of the None singleton.

def implementation_requested(unknown):

The type of the None singleton.

def references_requested(unknown):

The type of the None singleton.

def rename_requested(unknown):

The type of the None singleton.

def lint_fix_requested(unknown):

The type of the None singleton.

def word_added(unknown):

The type of the None singleton.

def explain_requested(unknown):

The type of the None singleton.

def edit_selection_requested(unknown):

The type of the None singleton.

def typed(unknown):

The type of the None singleton.

def completion_chosen(unknown):

The type of the None singleton.

def cursors_changed(unknown):

The type of the None singleton.

def breakpoints_changed(unknown):

The type of the None singleton.

indent_width = 4
use_tabs = False
highlight_matches = True
show_coverage = True
MAX_MATCHES = 2000
hover_provider: Callable | None
hover_popup
completion_popup
signature_popup
markers: dict[int, list[tuple[str, str]]]
change_marks: dict[int, str]
deletions: dict[int, tuple[str, int]]
debuggable
breakpoint_problems: dict[int, str]
execution_line: int | None
commit_lines: tuple[set[int], PySide6.QtGui.QColor] | None
minimap
find_selections: list[tuple[int, int]]
coverage: dict[int, int]
coverage_branches: dict[int, tuple[int, int]]
occurrence_selections: list[tuple[int, int]]
match_options: Callable[[], tuple[bool, bool]]
lint_issues: list[vibefpmgui.lint.LintIssue]
gremlins: dict[int, list[vibefpmgui.gremlins.Gremlin]]
extra_cursors: list[PySide6.QtGui.QTextCursor]
gutter
def toPlainText(self) -> str:
141    def toPlainText(self) -> str:
142        """The text exactly as it is (see highlighter.document_text): what's
143        saved, linted, searched and sent to the language server."""
144        return document_text(self.document())

The text exactly as it is (see highlighter.document_text): what's saved, linted, searched and sent to the language server.

def resizeEvent(self, event):
146    def resizeEvent(self, event):
147        super().resizeEvent(event)
148        cr = self.contentsRect()
149        self.gutter.setGeometry(QRect(cr.left(), cr.top(), self.gutter_width(), cr.height()))
150        if self.lineWrapMode() != QPlainTextEdit.NoWrap:  # (lines re-wrap: highlight their new rows)
151            QTimer.singleShot(0, self, self.update_extra_selections)
152        else:
153            self._scrolled()  # (more or fewer lines on view)

resizeEvent(self, e: PySide6.QtGui.QResizeEvent, /) -> None

def set_word_wrap(self, on: bool):
155    def set_word_wrap(self, on: bool):
156        """Wrap long lines at the window's edge (at a space if there is
157        one), or scroll sideways."""
158        self.setWordWrapMode(QTextOption.WrapAtWordBoundaryOrAnywhere)
159        self.setLineWrapMode(QPlainTextEdit.WidgetWidth if on else QPlainTextEdit.NoWrap)
160        QTimer.singleShot(0, self, self.update_extra_selections)
161        self.gutter.update()
162        self._update_minimap()

Wrap long lines at the window's edge (at a space if there is one), or scroll sideways.

def wheelEvent(self, event):
168    def wheelEvent(self, event):
169        self.hide_popups()
170        super().wheelEvent(event)

wheelEvent(self, e: PySide6.QtGui.QWheelEvent, /) -> None

def focusOutEvent(self, event):
172    def focusOutEvent(self, event):
173        if not self.hover_popup.geometry().contains(QCursor.pos()):
174            self.hover_popup.hide()
175        if not self.completion_popup.geometry().contains(QCursor.pos()):
176            self.completion_popup.hide()  # (unless it's being double-clicked)
177        self.signature_popup.hide()
178        super().focusOutEvent(event)
179        if self.extra_cursors:
180            self._caret_timer.stop()
181            self.viewport().update()  # (no cursors without the focus, like Qt's)

focusOutEvent(self, e: PySide6.QtGui.QFocusEvent, /) -> None

def hideEvent(self, event):
183    def hideEvent(self, event):
184        self.hide_popups()
185        super().hideEvent(event)

hideEvent(self, event: PySide6.QtGui.QHideEvent, /) -> None

def mousePressEvent(self, event):
187    def mousePressEvent(self, event):
188        mods = event.modifiers()
189        if event.button() == Qt.LeftButton:
190            point = event.position().toPoint()
191            if mods & Qt.AltModifier and not mods & (Qt.ControlModifier | Qt.ShiftModifier):
192                self.toggle_cursor_at(self.cursorForPosition(point).position())  # ⌥/Alt-click
193                return
194            if mods & Qt.ControlModifier and mods & Qt.ShiftModifier:  # ⌘⇧/Ctrl+Shift-click
195                clicked = self.cursorForPosition(point)
196                self.add_column_cursors(clicked.blockNumber(), clicked.positionInBlock())
197                return
198            self.clear_extra_cursors()  # (any other click: back to one cursor)
199        if (event.button() == Qt.LeftButton and event.modifiers() & Qt.ControlModifier
200                and self.hover_provider is not None):
201            # ⌘-click (Ctrl-click on Linux/Windows): go to the definition.
202            self.setTextCursor(self.cursorForPosition(event.position().toPoint()))
203            self.definition_requested.emit()
204            return
205        super().mousePressEvent(event)

mousePressEvent(self, e: PySide6.QtGui.QMouseEvent, /) -> None

def contextMenuEvent(self, event):
207    def contextMenuEvent(self, event):
208        menu = self.context_menu(event.pos(), from_mouse=event.reason() == event.Reason.Mouse)
209        menu.exec(event.globalPos())
210        menu.deleteLater()

contextMenuEvent(self, e: PySide6.QtGui.QContextMenuEvent, /) -> None

def context_menu( self, pos: PySide6.QtCore.QPoint, from_mouse: bool = True) -> PySide6.QtWidgets.QMenu:
212    def context_menu(self, pos: QPoint, from_mouse: bool = True) -> QMenu:
213        """The usual Cut/Copy/Paste menu, plus fortitude's fixes for the
214        issues at that spot and the language server's commands for the name
215        there (when one is attached). ``pos``: where the menu was asked for
216        (viewport coordinates); ``from_mouse`` False: from the keyboard, for
217        the text cursor's spot."""
218        menu = self.createStandardContextMenu(pos)
219        # fortitude's fixes for the issues at that spot.
220        spot = self.cursorForPosition(pos).position() if from_mouse else self.textCursor().position()
221        fixes = [i for i in self.lint_issues if i.fixable
222                 and (span := self._lint_span(i)) and span[0] <= spot <= span[1]]
223        misspelled = next((i for i in self.lint_issues if i.severity == "spelling"
224                           and (span := self._lint_span(i)) and span[0] <= spot <= span[1]), None)
225        if misspelled is not None:
226            first = menu.actions()[0] if menu.actions() else None
227            words = misspelled.suggestions()
228            for word in words:
229                action = QAction(word, menu)
230                font = action.font()
231                font.setBold(True)
232                action.setFont(font)
233                action.triggered.connect(lambda _=False, i=misspelled, w=word: self.replace_word(i, w))
234                menu.insertAction(first, action)
235            if not words:
236                none = QAction("(No Spelling Suggestions)", menu)
237                none.setEnabled(False)
238                menu.insertAction(first, none)
239            add = QAction(f"Add “{misspelled.word}” to Dictionary", menu)
240            add.triggered.connect(lambda _=False, w=misspelled.word: self.word_added.emit(w))
241            menu.insertAction(first, add)
242            menu.insertSeparator(first)
243        if self.textCursor().hasSelection():
244            first = menu.actions()[0] if menu.actions() else None
245            edit = QAction("Edit Selection with AI…", menu)
246            edit.setToolTip("Ask the local AI model to change the selected lines; you accept or reject each edit")
247            edit.triggered.connect(self.edit_selection_requested.emit)
248            menu.insertAction(first, edit)
249            menu.insertSeparator(first)
250        line = self.document().findBlock(spot).blockNumber() + 1
251        if any(severity not in ("review", "remark") for severity, _ in self.markers.get(line, [])):
252            first = menu.actions()[0] if menu.actions() else None
253            explain = QAction("Explain Error with AI", menu)
254            explain.setToolTip("Ask the local AI model what this line's problem means, and how to fix it")
255            explain.triggered.connect(lambda _=False, n=line: self.explain_requested.emit(n))
256            menu.insertAction(first, explain)
257            menu.insertSeparator(first)
258        if fixes:
259            first = menu.actions()[0] if menu.actions() else None
260            for issue in fixes:
261                label = f"Fix: {issue.fix or issue.message}" + ("" if issue.fix_safe else "  (unsafe — check it)")
262                action = QAction(label, menu)
263                action.triggered.connect(lambda _=False, i=issue: self.lint_fix_requested.emit(i))
264                menu.insertAction(first, action)
265            menu.insertSeparator(first)
266        if self.hover_provider is not None:
267            on_name = False
268            if from_mouse:
269                point = pos  # (already in viewport coordinates)
270                on_name = self.word_at(point) is not None
271                if on_name:  # the commands act on the name at the cursor
272                    cursor = self.cursorForPosition(point)
273                    if not (self.textCursor().selectionStart() <= cursor.position()
274                            <= self.textCursor().selectionEnd() and self.textCursor().hasSelection()):
275                        self.setTextCursor(cursor)
276            else:  # opened from the keyboard: the name at the cursor
277                rect = self.cursorRect()
278                on_name = self.word_at(rect.center()) is not None
279            first = menu.actions()[0] if menu.actions() else None
280            for text, keys, signal in (("Go to Definition", "F12", self.definition_requested),
281                                       ("Go to Implementation", "Ctrl+F12", self.implementation_requested),
282                                       ("Find References", "Shift+F12", self.references_requested),
283                                       ("Rename Symbol…", "F2", self.rename_requested)):
284                action = QAction(text, menu)
285                action.setShortcut(QKeySequence(keys))  # shown in the menu; the Edit menu's work
286                action.setShortcutContext(Qt.WidgetShortcut)
287                action.setEnabled(on_name)
288                action.triggered.connect(signal.emit)
289                menu.insertAction(first, action)
290            menu.insertSeparator(first)
291        return menu

The usual Cut/Copy/Paste menu, plus fortitude's fixes for the issues at that spot and the language server's commands for the name there (when one is attached). pos: where the menu was asked for (viewport coordinates); from_mouse False: from the keyboard, for the text cursor's spot.

def mouseMoveEvent(self, event):
293    def mouseMoveEvent(self, event):
294        super().mouseMoveEvent(event)
295        if self.hover_popup.isVisible():
296            point = event.position().toPoint()
297            anchor = self.hover_popup.anchor
298            on_line = (isinstance(anchor, tuple) and anchor[:1] == ("line",)
299                       and self.cursorForPosition(point).blockNumber() + 1 == anchor[1])
300            if on_line or self.hover_key(point) == anchor:
301                self.hover_popup.keep()
302            else:
303                self.hover_popup.schedule_hide()
304        if self.hover_provider is not None and event.modifiers() & Qt.ControlModifier \
305                and self.word_at(event.position().toPoint()):
306            self.viewport().setCursor(Qt.PointingHandCursor)
307        else:
308            self.viewport().setCursor(Qt.IBeamCursor)

mouseMoveEvent(self, e: PySide6.QtGui.QMouseEvent, /) -> None

def go_to_position(self, line: int, column: int = 0):
310    def go_to_position(self, line: int, column: int = 0):
311        """Put the cursor at a line (1-based) and column (0-based)."""
312        self.go_to_line(line)
313        cursor = self.textCursor()
314        cursor.setPosition(cursor.block().position() + min(column, max(0, cursor.block().length() - 1)))
315        self.setTextCursor(cursor)
316        self.centerCursor()

Put the cursor at a line (1-based) and column (0-based).

def update_extra_selections(self, rebuild: bool = True):
319    def update_extra_selections(self, rebuild: bool = True):
320        """Current line, lines with errors/warnings, underlined issues,
321        gremlins, the extra cursors' selections, the selection's other
322        occurrences and find matches.
323
324        Only the lines around the view get highlights (see `_line_window`):
325        each is a QTextCursor that Qt moves along at every edit and goes
326        through at every cursor move, and a big file's thousands of issues
327        made typing, and replacing its text, take seconds. Scrolling makes
328        those of the lines coming into view. ``rebuild``: the markers,
329        issues or gremlins changed (or the colors, or the line wrapping).
330
331        Not while the text is being replaced (``setPlainText``, as a file
332        changed on disk is reloaded): emptying the document can narrow the
333        gutter and so scroll, calling this with no valid line anywhere, and a
334        cursor made from one crashed Qt. It's called again once the text is in."""
335        if not self.textCursor().block().isValid():
336            return
337        if rebuild:
338            self._highlights_version += 1
339            self._issue_lines = {}
340            for issue in self.lint_issues:
341                if issue.severity != "spelling":  # (drawn by paintEvent: see _paint_squiggles)
342                    self._issue_lines.setdefault(issue.line, []).append(issue)
343        window = self._line_window()
344        key = (self._highlights_version, window)
345        if key != self._fixed_key:
346            self._fixed_selections = self._marker_and_issue_selections(*window)
347            self._fixed_key = key
348        selections = list(self._fixed_selections)
349        if not self.isReadOnly():
350            color = QColor(self.palette().color(QPalette.Highlight))
351            color.setAlpha(28)
352            selections += self._line_highlight(self.textCursor().block(), color)
353        if self.execution_line:  # (where the debugger stopped)
354            block = self.document().findBlockByNumber(self.execution_line - 1)
355            if block.isValid():
356                tint = QColor(EXECUTION_COLOR)
357                tint.setAlpha(70)
358                selections += self._line_highlight(block, tint)
359        if self.commit_lines is not None:  # (a git blame card under the mouse: its commit's lines)
360            lines, tint = self.commit_lines
361            first, last = window
362            block = self.document().findBlockByNumber(first)
363            while block.isValid() and block.blockNumber() <= last:
364                if block.blockNumber() in lines:
365                    selections += self._line_highlight(block, tint)
366                block = block.next()
367        selected = QColor(self.palette().color(QPalette.Highlight))
368        for c in self.extra_cursors:
369            if c.hasSelection():
370                sel = QTextEdit.ExtraSelection()
371                sel.format.setBackground(selected)
372                sel.format.setForeground(self.palette().color(QPalette.HighlightedText))
373                sel.cursor = QTextCursor(c)
374                selections.append(sel)
375        selections += self._span_selections(self.occurrence_selections, self._occurrence_color, window)
376        selections += self._span_selections(self.find_selections, FIND_COLOR, window)
377        self.setExtraSelections(selections)

Current line, lines with errors/warnings, underlined issues, gremlins, the extra cursors' selections, the selection's other occurrences and find matches.

Only the lines around the view get highlights (see _line_window): each is a QTextCursor that Qt moves along at every edit and goes through at every cursor move, and a big file's thousands of issues made typing, and replacing its text, take seconds. Scrolling makes those of the lines coming into view. rebuild: the markers, issues or gremlins changed (or the colors, or the line wrapping).

Not while the text is being replaced (setPlainText, as a file changed on disk is reloaded): emptying the document can narrow the gutter and so scroll, calling this with no valid line anywhere, and a cursor made from one crashed Qt. It's called again once the text is in.

def show_commit_lines( self, lines: set[int] | None, color: PySide6.QtGui.QColor | None = None):
379    def show_commit_lines(self, lines: set[int] | None, color: QColor | None = None):
380        """Tint these lines (0-based; None: none) in ``color``: a git blame
381        card's commit, while the mouse is on its card."""
382        self.commit_lines = (lines, color) if lines else None
383        self.update_extra_selections(rebuild=False)

Tint these lines (0-based; None: none) in color: a git blame card's commit, while the mouse is on its card.

def update_occurrences(self):
479    def update_occurrences(self):
480        """Highlight the other occurrences of the selected text in the file
481        (single-line selections of 2 to 200 characters; Match case and Whole
482        words as set in the find bar)."""
483        found: list[tuple[int, int]] = []
484        cursor = self.textCursor()
485        selected = cursor.selectedText()
486        if (self.highlight_matches and len(self.extra_cursors) == 0 and 2 <= len(selected) <= 200
487                and selected.strip() and "\u2029" not in selected):
488            case, word = self.match_options()
489            pattern = re.escape(selected)
490            if word:  # (as the find bar: "_" is part of a word, so count doesn't match in count_all)
491                pattern = rf"(?<!\w){pattern}(?!\w)"
492            text = self.toPlainText()
493            simple = text.isascii()  # (else Qt counts characters beyond U+FFFF twice)
494            self._occurrence_color = occurrence_color(self.palette().color(QPalette.Base))
495            mine = cursor.selectionStart()
496            for match in re.finditer(pattern, text, 0 if case else re.IGNORECASE):
497                start, end = match.span()
498                if not simple:
499                    start, end = self._qt_position(text, start), self._qt_position(text, end)
500                if start == mine:
501                    continue  # (the selection itself)
502                found.append((start, end))
503                if len(found) >= self.MAX_MATCHES:
504                    break
505        if found or self.occurrence_selections:
506            self.occurrence_selections = found
507            self.update_extra_selections(rebuild=False)

Highlight the other occurrences of the selected text in the file (single-line selections of 2 to 200 characters; Match case and Whole words as set in the find bar).

def set_find_selections(self, spans: list[tuple[int, int]]):
509    def set_find_selections(self, spans: list[tuple[int, int]]):
510        """The find bar's matches: (start, end) positions, in order."""
511        self.find_selections = spans
512        self.update_extra_selections(rebuild=False)  # (kept apart: see update_extra_selections)
513        self._update_minimap()

The find bar's matches: (start, end) positions, in order.

def paintEvent(self, event):
515    def paintEvent(self, event):
516        super().paintEvent(event)
517        self._paint_squiggles(event.rect())
518        if self.extra_cursors and self._caret_on and self.hasFocus():  # (Qt's own is hidden: see _caret_width)
519            painter = QPainter(self.viewport())
520            color = self.palette().color(QPalette.Text)
521            for c in (self.textCursor(), *self.extra_cursors):
522                rect = self.cursorRect(c)
523                painter.fillRect(QRect(rect.left(), rect.top(), self._caret_width, rect.height()), color)
524            painter.end()

paintEvent(self, e: PySide6.QtGui.QPaintEvent, /) -> None

def focusInEvent(self, event):
526    def focusInEvent(self, event):
527        super().focusInEvent(event)
528        self._restart_blink()

focusInEvent(self, e: PySide6.QtGui.QFocusEvent, /) -> None

def keyPressEvent(self, event):
530    def keyPressEvent(self, event):
531        key = event.key()
532        if key in MODIFIER_KEYS:
533            # ⌘/⇧/⌥/⌃ on their own (e.g. to ⌘-click, or for a screenshot):
534            # keep the hover popup open.
535            super().keyPressEvent(event)
536            return
537        self.hover_popup.hide()
538        if self.extra_cursors and self._multi_cursor_key(event):
539            return
540        popup = self.completion_popup
541        plain = not event.modifiers() & (Qt.ShiftModifier | Qt.ControlModifier | Qt.AltModifier | Qt.MetaModifier)
542        if popup.isVisible():
543            steps = {Qt.Key_Up: -1, Qt.Key_Down: 1,
544                     Qt.Key_PageUp: -popup.page_rows(), Qt.Key_PageDown: popup.page_rows()}
545            if key in steps and plain:
546                popup.move_selection(steps[key])
547                return
548            if key in (Qt.Key_Return, Qt.Key_Enter, Qt.Key_Tab) and plain:
549                self.completion_chosen.emit(popup.current())
550                return
551            if key == Qt.Key_Escape:
552                popup.hide()
553                return
554        if key == Qt.Key_Escape and self.signature_popup.isVisible():
555            self.signature_popup.hide()
556            return
557        if key in (Qt.Key_Up, Qt.Key_Down) and event.modifiers() == Qt.AltModifier:
558            # ⌥↑ / ⌥↓ (Alt+↑ / ↓): Move Line Up / Down. (Here too, not only as the
559            # menu's shortcut: on macOS the text field takes these keys for itself.)
560            self.move_lines(down=key == Qt.Key_Down)
561            return
562        before = self.document().revision()
563        self._edit_key(event)
564        if self.document().revision() != before:
565            self.typed.emit("\b" if key == Qt.Key_Backspace else event.text())

keyPressEvent(self, e: PySide6.QtGui.QKeyEvent, /) -> None

def tab_text(self, cursor: PySide6.QtGui.QTextCursor) -> str:
604    def tab_text(self, cursor: QTextCursor) -> str:
605        """What Tab types at a cursor (see `tab_text`)."""
606        return tab_text(cursor.block().text()[:cursor.positionInBlock()], self.indent_width, self.use_tabs)

What Tab types at a cursor (see tab_text).

def indent_unit(self) -> str:
608    def indent_unit(self) -> str:
609        """One indent level: a tab, or ``indent_width`` spaces."""
610        return "\t" if self.use_tabs else " " * self.indent_width

One indent level: a tab, or indent_width spaces.

def set_indentation(self, width: int, use_tabs: bool):
612    def set_indentation(self, width: int, use_tabs: bool):
613        """View → Indentation (tabs are shown ``width`` columns wide too)."""
614        self.indent_width, self.use_tabs = width, use_tabs
615        self.setTabStopDistance(self.fontMetrics().horizontalAdvance(" ") * width)

View → Indentation (tabs are shown width columns wide too).

def replace_ranges(self, ranges: list[tuple[int, int, str]]):
646    def replace_ranges(self, ranges: list[tuple[int, int, str]]):
647        """Replace (start, end, new text) ranges, given as offsets in
648        toPlainText(), as one step to undo."""
649        text = self.toPlainText()
650        cursor = QTextCursor(self.document())
651        cursor.beginEditBlock()
652        for start, end, new in sorted(ranges, key=lambda r: r[0], reverse=True):  # (from the end: offsets stay right)
653            cursor.setPosition(self._qt_position(text, start))
654            cursor.setPosition(self._qt_position(text, end), QTextCursor.KeepAnchor)
655            cursor.insertText(new)
656        cursor.endEditBlock()

Replace (start, end, new text) ranges, given as offsets in toPlainText(), as one step to undo.

def select_range(self, start: int, end: int):
658    def select_range(self, start: int, end: int):
659        """Select (and show) a range given as offsets in toPlainText()."""
660        text = self.toPlainText()
661        cursor = QTextCursor(self.document())
662        cursor.setPosition(self._qt_position(text, end))
663        cursor.setPosition(self._qt_position(text, start), QTextCursor.KeepAnchor)
664        self.setTextCursor(cursor)
665        self.centerCursor()

Select (and show) a range given as offsets in toPlainText().

def selected_lines(self) -> tuple[int, int] | None:
667    def selected_lines(self) -> tuple[int, int] | None:
668        """The first and last lines of the selection (1-based), if any; a
669        selection ending at the start of a line doesn't include that line."""
670        cursor = self.textCursor()
671        if not cursor.hasSelection():
672            return None
673        doc = self.document()
674        first = doc.findBlock(cursor.selectionStart()).blockNumber() + 1
675        end_block = doc.findBlock(cursor.selectionEnd())
676        last = end_block.blockNumber() + (0 if cursor.selectionEnd() == end_block.position() else 1)
677        return first, max(first, last)

The first and last lines of the selection (1-based), if any; a selection ending at the start of a line doesn't include that line.

def go_to_line(self, line: int, select: bool = False):
679    def go_to_line(self, line: int, select: bool = False):
680        block = self.document().findBlockByNumber(max(0, line - 1))
681        if not block.isValid():
682            block = self.document().lastBlock()
683        cursor = QTextCursor(block)
684        if select:
685            cursor.movePosition(QTextCursor.EndOfBlock, QTextCursor.KeepAnchor)
686        self.setTextCursor(cursor)
687        self.centerCursor()
688        self.setFocus()
staticMetaObject = PySide6.QtCore.QMetaObject("CodeEditor" inherits "QPlainTextEdit": Methods: #62 type=Signal, signature=definition_requested() #63 type=Signal, signature=implementation_requested() #64 type=Signal, signature=references_requested() #65 type=Signal, signature=rename_requested() #66 type=Signal, signature=lint_fix_requested(PyObject), parameters=PyObject #67 type=Signal, signature=word_added(QString), parameters=QString #68 type=Signal, signature=explain_requested(int), parameters=int #69 type=Signal, signature=edit_selection_requested() #70 type=Signal, signature=typed(QString), parameters=QString #71 type=Signal, signature=completion_chosen(PyObject), parameters=PyObject #72 type=Signal, signature=cursors_changed() #73 type=Signal, signature=breakpoints_changed() )