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()
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.
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()
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.
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
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.
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
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
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
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
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).
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.
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.
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).
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.
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
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
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).
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.
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).
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.
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().
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.
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()
Inherited Members
- vibefpmgui.editor_gutter.EditorGutterMixin
- gutter_width
- breakpoint_lines
- disabled_breakpoints
- enabled_breakpoints
- set_breakpoint_lines
- toggle_breakpoint
- toggle_breakpoint_enabled
- set_breakpoints_enabled
- set_breakpoint_problems
- breakpoint_tip
- set_execution_line
- set_coverage
- coverage_state
- missed_starts
- go_to_missed
- refresh_gutter
- coverage_note
- set_gremlins
- gremlin_notes
- update_gutter_width
- paint_gutter
- set_change_marks
- line_at_y
- set_markers
- vibefpmgui.editor_issues.EditorIssuesMixin
- set_lint
- replace_word
- word_at
- lint_card_html
- hover_key
- viewportEvent
- show_hover
- hide_popups
- squiggle_spans