Coverage for vibefpmgui/code_editor.py: 91%

523 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-11-01 20:51 +0000

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: 188 ↛ 198line 188 didn't jump to line 198 because the condition on line 188 was always true

189 point = event.position().toPoint() 

190 if mods & Qt.AltModifier and not mods & (Qt.ControlModifier | Qt.ShiftModifier): 

191 self.toggle_cursor_at(self.cursorForPosition(point).position()) # ⌥/Alt-click 

192 return 

193 if mods & Qt.ControlModifier and mods & Qt.ShiftModifier: # ⌘⇧/Ctrl+Shift-click 

194 clicked = self.cursorForPosition(point) 

195 self.add_column_cursors(clicked.blockNumber(), clicked.positionInBlock()) 

196 return 

197 self.clear_extra_cursors() # (any other click: back to one cursor) 

198 if (event.button() == Qt.LeftButton and event.modifiers() & Qt.ControlModifier 198 ↛ 201line 198 didn't jump to line 201 because the condition on line 198 was never true

199 and self.hover_provider is not None): 

200 # ⌘-click (Ctrl-click on Linux/Windows): go to the definition. 

201 self.setTextCursor(self.cursorForPosition(event.position().toPoint())) 

202 self.definition_requested.emit() 

203 return 

204 super().mousePressEvent(event) 

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: 234 ↛ 235line 234 didn't jump to line 235 because the condition on line 234 was never true

235 none = QAction("(No Spelling Suggestions)", menu) 

236 none.setEnabled(False) 

237 menu.insertAction(first, none) 

238 add = QAction(f"Add “{misspelled.word}” to Dictionary", menu) 

239 add.triggered.connect(lambda _=False, w=misspelled.word: self.word_added.emit(w)) 

240 menu.insertAction(first, add) 

241 menu.insertSeparator(first) 

242 if self.textCursor().hasSelection(): 

243 first = menu.actions()[0] if menu.actions() else None 

244 edit = QAction("Edit Selection with AI…", menu) 

245 edit.setToolTip("Ask the local AI model to change the selected lines; you accept or reject each edit") 

246 edit.triggered.connect(self.edit_selection_requested.emit) 

247 menu.insertAction(first, edit) 

248 menu.insertSeparator(first) 

249 line = self.document().findBlock(spot).blockNumber() + 1 

250 if any(severity not in ("review", "remark") for severity, _ in self.markers.get(line, [])): 

251 first = menu.actions()[0] if menu.actions() else None 

252 explain = QAction("Explain Error with AI", menu) 

253 explain.setToolTip("Ask the local AI model what this line's problem means, and how to fix it") 

254 explain.triggered.connect(lambda _=False, n=line: self.explain_requested.emit(n)) 

255 menu.insertAction(first, explain) 

256 menu.insertSeparator(first) 

257 if fixes: 

258 first = menu.actions()[0] if menu.actions() else None 

259 for issue in fixes: 

260 label = f"Fix: {issue.fix or issue.message}" + ("" if issue.fix_safe else " (unsafe — check it)") 

261 action = QAction(label, menu) 

262 action.triggered.connect(lambda _=False, i=issue: self.lint_fix_requested.emit(i)) 

263 menu.insertAction(first, action) 

264 menu.insertSeparator(first) 

265 if self.hover_provider is not None: 

266 on_name = False 

267 if from_mouse: 267 ↛ 276line 267 didn't jump to line 276 because the condition on line 267 was always true

268 point = pos # (already in viewport coordinates) 

269 on_name = self.word_at(point) is not None 

270 if on_name: # the commands act on the name at the cursor 

271 cursor = self.cursorForPosition(point) 

272 if not (self.textCursor().selectionStart() <= cursor.position() 272 ↛ 278line 272 didn't jump to line 278 because the condition on line 272 was always true

273 <= self.textCursor().selectionEnd() and self.textCursor().hasSelection()): 

274 self.setTextCursor(cursor) 

275 else: # opened from the keyboard: the name at the cursor 

276 rect = self.cursorRect() 

277 on_name = self.word_at(rect.center()) is not None 

278 first = menu.actions()[0] if menu.actions() else None 

279 for text, keys, signal in (("Go to Definition", "F12", self.definition_requested), 

280 ("Go to Implementation", "Ctrl+F12", self.implementation_requested), 

281 ("Find References", "Shift+F12", self.references_requested), 

282 ("Rename Symbol…", "F2", self.rename_requested)): 

283 action = QAction(text, menu) 

284 action.setShortcut(QKeySequence(keys)) # shown in the menu; the Edit menu's work 

285 action.setShortcutContext(Qt.WidgetShortcut) 

286 action.setEnabled(on_name) 

287 action.triggered.connect(signal.emit) 

288 menu.insertAction(first, action) 

289 menu.insertSeparator(first) 

290 return menu 

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(): 348 ↛ 352line 348 didn't jump to line 352 because the condition on line 348 was always true

349 color = QColor(self.palette().color(QPalette.Highlight)) 

350 color.setAlpha(28) 

351 selections += self._line_highlight(self.textCursor().block(), color) 

352 if self.execution_line: # (where the debugger stopped) 

353 block = self.document().findBlockByNumber(self.execution_line - 1) 

354 if block.isValid(): 354 ↛ 358line 354 didn't jump to line 358 because the condition on line 354 was always true

355 tint = QColor(EXECUTION_COLOR) 

356 tint.setAlpha(70) 

357 selections += self._line_highlight(block, tint) 

358 if self.commit_lines is not None: # (a git blame card under the mouse: its commit's lines) 

359 lines, tint = self.commit_lines 

360 first, last = window 

361 block = self.document().findBlockByNumber(first) 

362 while block.isValid() and block.blockNumber() <= last: 

363 if block.blockNumber() in lines: 

364 selections += self._line_highlight(block, tint) 

365 block = block.next() 

366 selected = QColor(self.palette().color(QPalette.Highlight)) 

367 for c in self.extra_cursors: 

368 if c.hasSelection(): 

369 sel = QTextEdit.ExtraSelection() 

370 sel.format.setBackground(selected) 

371 sel.format.setForeground(self.palette().color(QPalette.HighlightedText)) 

372 sel.cursor = QTextCursor(c) 

373 selections.append(sel) 

374 selections += self._span_selections(self.occurrence_selections, self._occurrence_color, window) 

375 selections += self._span_selections(self.find_selections, FIND_COLOR, window) 

376 self.setExtraSelections(selections) 

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: 406 ↛ 407line 406 didn't jump to line 407 because the condition on line 406 was never true

407 break 

408 sel = QTextEdit.ExtraSelection() 

409 sel.format.setBackground(color) 

410 sel.cursor = QTextCursor(doc) 

411 sel.cursor.setPosition(start) 

412 sel.cursor.setPosition(end, QTextCursor.KeepAnchor) 

413 out.append(sel) 

414 return out 

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: 437 ↛ 438line 437 didn't jump to line 438 because the condition on line 437 was never true

438 continue 

439 sel = QTextEdit.ExtraSelection() 

440 sel.format.setUnderlineStyle(QTextCharFormat.WaveUnderline) 

441 sel.format.setUnderlineColor(QColor(MARKER_COLORS[issue.severity])) 

442 sel.cursor = QTextCursor(self.document()) 

443 sel.cursor.setPosition(span[0]) 

444 sel.cursor.setPosition(span[1], QTextCursor.KeepAnchor) 

445 issues.append(sel) 

446 for g in self.gremlins.get(line, ()): # (a tint: an invisible one would show nothing else) 

447 if g.column >= block.length() - 1: 

448 continue 

449 sel = QTextEdit.ExtraSelection() 

450 sel.format.setBackground(tint) 

451 sel.cursor = QTextCursor(block) 

452 sel.cursor.setPosition(block.position() + g.column) 

453 sel.cursor.setPosition(block.position() + g.column + 1, QTextCursor.KeepAnchor) 

454 gremlins.append(sel) 

455 block = block.next() 

456 return coverage + markers + issues + gremlins 

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(): 464 ↛ 465line 464 didn't jump to line 465 because the condition on line 464 was never true

465 return [] 

466 layout = block.layout() 

467 starts = [layout.lineAt(i).textStart() for i in range(layout.lineCount())] if layout else [] 

468 out = [] 

469 for start in starts or [0]: 

470 sel = QTextEdit.ExtraSelection() 

471 sel.format.setBackground(color) 

472 sel.format.setProperty(QTextFormat.FullWidthSelection, True) 

473 sel.cursor = QTextCursor(block) 

474 sel.cursor.setPosition(block.position() + start) 

475 out.append(sel) 

476 return out 

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: 497 ↛ 498line 497 didn't jump to line 498 because the condition on line 497 was never true

498 start, end = self._qt_position(text, start), self._qt_position(text, end) 

499 if start == mine: 

500 continue # (the selection itself) 

501 found.append((start, end)) 

502 if len(found) >= self.MAX_MATCHES: 502 ↛ 503line 502 didn't jump to line 503 because the condition on line 502 was never true

503 break 

504 if found or self.occurrence_selections: 

505 self.occurrence_selections = found 

506 self.update_extra_selections(rebuild=False) 

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: 547 ↛ 550line 547 didn't jump to line 550 because the condition on line 547 was always true

548 self.completion_chosen.emit(popup.current()) 

549 return 

550 if key == Qt.Key_Escape: 

551 popup.hide() 

552 return 

553 if key == Qt.Key_Escape and self.signature_popup.isVisible(): 553 ↛ 554line 553 didn't jump to line 554 because the condition on line 553 was never true

554 self.signature_popup.hide() 

555 return 

556 if key in (Qt.Key_Up, Qt.Key_Down) and event.modifiers() == Qt.AltModifier: 556 ↛ 559line 556 didn't jump to line 559 because the condition on line 556 was never true

557 # ⌥↑ / ⌥↓ (Alt+↑ / ↓): Move Line Up / Down. (Here too, not only as the 

558 # menu's shortcut: on macOS the text field takes these keys for itself.) 

559 self.move_lines(down=key == Qt.Key_Down) 

560 return 

561 before = self.document().revision() 

562 self._edit_key(event) 

563 if self.document().revision() != before: 563 ↛ exitline 563 didn't return from function 'keyPressEvent' because the condition on line 563 was always true

564 self.typed.emit("\b" if key == Qt.Key_Backspace else event.text()) 

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: 592 ↛ 601line 592 didn't jump to line 601 because the condition on line 592 was always true

593 # Backspace in leading spaces goes back to the previous tab stop. 

594 cursor = self.textCursor() 

595 count = backspace_length(cursor.block().text()[:cursor.positionInBlock()], self.indent_width) 

596 if count: 596 ↛ 601line 596 didn't jump to line 601 because the condition on line 596 was always true

597 cursor.movePosition(QTextCursor.Left, QTextCursor.KeepAnchor, count) 

598 cursor.removeSelectedText() # (one Undo step) 

599 self.setTextCursor(cursor) 

600 return 

601 super().keyPressEvent(event) 

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(): 626 ↛ 638line 626 didn't jump to line 638 because the condition on line 626 was always true

627 c = QTextCursor(block) 

628 if direction > 0: 

629 if block.text().strip() or block == start == end: # (blank lines stay blank) 629 ↛ 635line 629 didn't jump to line 635 because the condition on line 629 was always true

630 c.insertText(self.indent_unit()) 

631 else: 

632 c.movePosition(QTextCursor.Right, QTextCursor.KeepAnchor, 

633 outdent_length(block.text(), self.indent_width)) 

634 c.removeSelectedText() 

635 if block == end: 

636 break 

637 block = block.next() 

638 cursor.endEditBlock() 

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(): 680 ↛ 681line 680 didn't jump to line 681 because the condition on line 680 was never true

681 block = self.document().lastBlock() 

682 cursor = QTextCursor(block) 

683 if select: 

684 cursor.movePosition(QTextCursor.EndOfBlock, QTextCursor.KeepAnchor) 

685 self.setTextCursor(cursor) 

686 self.centerCursor() 

687 self.setFocus()