vibefpmgui.editor_gutter
The editor's gutter: breakpoints, line numbers, change bars, coverage, gremlins and markers.
A part of vibefpmgui.code_editor.CodeEditor.
1"""The editor's gutter: breakpoints, line numbers, change bars, coverage, gremlins and markers. 2 3A part of `vibefpmgui.code_editor.CodeEditor`.""" 4 5from __future__ import annotations 6 7import sys 8from typing import TYPE_CHECKING 9 10from qtpy.QtCore import QPointF, QRect, QRectF, QSize, Qt 11from qtpy.QtGui import QColor, QFont, QPainter, QPalette, QPen, QPolygonF, QTextCursor 12from qtpy.QtWidgets import QToolTip, QWidget 13 14from . import gremlins as gremlins_mod 15from .editor_shared import ( 16 BREAKPOINT_COLOR, 17 BREAKPOINT_UNPLACED, 18 BREAKPOINT_WIDTH, 19 CHANGE_COLORS, 20 COVERAGE_COLORS, 21 COVERAGE_WIDTH, 22 EXECUTION_COLOR, 23 GREMLIN_WIDTH, 24 MARKER_COLORS, 25 worst_severity, 26) 27 28if TYPE_CHECKING: 29 from .code_editor import CodeEditor 30from . import hover_card 31 32 33class LineNumberArea(QWidget): 34 """The gutter: line numbers plus warning/error markers.""" 35 36 def __init__(self, editor: CodeEditor): 37 super().__init__(editor) 38 self.editor = editor 39 self.setMouseTracking(True) 40 41 def sizeHint(self): 42 return QSize(self.editor.gutter_width(), 0) 43 44 def paintEvent(self, event): 45 self.editor.paint_gutter(event) 46 47 def event(self, event): 48 if event.type() == event.Type.ToolTip: 49 line = self.editor.line_at_y(event.pos().y()) 50 if event.pos().x() < self.editor._breakpoint_column(): 51 tip = self.editor.breakpoint_tip(line) 52 if tip: 53 QToolTip.showText(event.globalPos(), tip, self) 54 else: 55 QToolTip.hideText() 56 return True 57 origin = self.editor._breakpoint_column() 58 if origin <= event.pos().x() < origin + self.editor._coverage_column() and self.editor.coverage_note(line): 59 QToolTip.showText(event.globalPos(), self.editor.coverage_note(line), self) 60 return True 61 change_tip = self._change_tip(event.pos(), line) 62 if change_tip: # over a change bar or a deletion marker 63 QToolTip.showText(event.globalPos(), change_tip, self) 64 return True 65 notes = self.editor.markers.get(line) 66 # (Spelling has no gutter marks: its notes are on the words only.) 67 issues = [i for i in self.editor.lint_issues if i.line == line and i.severity != "spelling"] 68 popup = self.editor.hover_popup 69 if issues: # the same card as hovering the text (with its fix links) 70 if not (popup.isVisible() and popup.anchor == ("gutter", line)): 71 # (fortitude's and fortls's notes are the card's issues already) 72 others = [m for severity, m in notes or [] if severity not in hover_card.LINT_COLORS] 73 popup.show_at(event.globalPos(), self.editor.lint_card_html(issues, others), ("gutter", line)) 74 QToolTip.hideText() 75 elif notes or line in self.editor.gremlins: 76 lines = [m for _, m in notes or []] + self.editor.gremlin_notes(line) 77 QToolTip.showText(event.globalPos(), "\n".join(lines), self) 78 else: 79 QToolTip.hideText() 80 return True 81 return super().event(event) 82 83 def _change_tip(self, pos, line: int) -> str: 84 """Tooltip for the change bars at the gutter's right edge.""" 85 if pos.x() < self.width() - 9: 86 return "" 87 editor = self.editor 88 block = editor.document().findBlockByNumber(line - 1) 89 if block.isValid(): 90 top = editor.blockBoundingGeometry(block).translated(editor.contentOffset()).top() 91 bottom = top + editor.blockBoundingRect(block).height() 92 for deleted_at, y in ((line, top), (line + 1, bottom)): 93 if deleted_at in editor.deletions and abs(pos.y() - y) <= 5: 94 kind, count = editor.deletions[deleted_at] 95 what = f"{count} line{'s' * (count != 1)} deleted here" 96 return what + (" — not saved yet" if kind == "unsaved" else " (saved)") 97 change = editor.change_marks.get(line) 98 if change == "unsaved": 99 return "Changed — not saved yet" 100 if change == "saved": 101 return "Changed since the file was opened (saved)" 102 return "" 103 104 def mouseMoveEvent(self, event): 105 popup = self.editor.hover_popup 106 if popup.isVisible() and popup.anchor and popup.anchor[0] == "gutter": 107 if popup.anchor == ("gutter", self.editor.line_at_y(event.position().toPoint().y())): 108 popup.keep() 109 else: 110 popup.schedule_hide() 111 super().mouseMoveEvent(event) 112 113 def leaveEvent(self, event): 114 self.editor.hover_popup.schedule_hide() # (unless the mouse went into it) 115 super().leaveEvent(event) 116 117 def mousePressEvent(self, event): 118 # Clicking the breakpoint column sets or clears one; a number selects that line. 119 line = self.editor.line_at_y(event.pos().y()) 120 if line and event.pos().x() < self.editor._breakpoint_column(): 121 if event.modifiers() & Qt.ControlModifier: # (⌘ on macOS: disable or enable it) 122 self.editor.toggle_breakpoint_enabled(line) 123 else: 124 self.editor.toggle_breakpoint(line) 125 elif line: 126 self.editor.go_to_line(line, select=True) 127 128 129class EditorGutterMixin: 130 """The editor's gutter (a part of `vibefpmgui.code_editor.CodeEditor`).""" 131 132 def gutter_width(self) -> int: 133 digits = max(3, len(str(max(1, self.blockCount())))) 134 return (22 + self.fontMetrics().horizontalAdvance("9") * digits + self._coverage_column() 135 + self._gremlin_column() + self._breakpoint_column()) 136 137 def _breakpoint_column(self) -> int: 138 return BREAKPOINT_WIDTH if self.debuggable else 0 139 140 # ------------------------------------------------------------ breakpoints 141 def breakpoint_lines(self) -> list[int]: 142 """The lines (1-based) with a breakpoint, enabled or not, as they are 143 now (they move with edits).""" 144 return sorted({cursor.blockNumber() + 1 for cursor, _on in self._breakpoints}) 145 146 def disabled_breakpoints(self) -> list[int]: 147 """The lines whose breakpoint is disabled (kept, drawn hollow; the debugger doesn't stop there).""" 148 enabled = {cursor.blockNumber() + 1 for cursor, on in self._breakpoints if on} 149 return sorted({cursor.blockNumber() + 1 for cursor, on in self._breakpoints if not on} - enabled) 150 151 def enabled_breakpoints(self) -> list[int]: 152 disabled = set(self.disabled_breakpoints()) 153 return [n for n in self.breakpoint_lines() if n not in disabled] 154 155 def set_breakpoint_lines(self, lines: list[int], disabled: list[int] | None = None): 156 """Breakpoints on these lines (those past the end: dropped); ``disabled``: these of them, disabled.""" 157 doc = self.document() 158 off = set(disabled or ()) 159 self._breakpoints = [] 160 for line in sorted(set(lines)): 161 block = doc.findBlockByNumber(line - 1) 162 if block.isValid(): 163 self._breakpoints.append((QTextCursor(block), line not in off)) 164 self.breakpoint_problems = {} 165 self.gutter.update() 166 167 def toggle_breakpoint(self, line: int): 168 """A breakpoint on ``line`` set, or removed (gutter click, F9).""" 169 if not self.debuggable: 170 return 171 lines, off = self.breakpoint_lines(), self.disabled_breakpoints() 172 self.set_breakpoint_lines([n for n in lines if n != line] if line in lines else [*lines, line], off) 173 self.breakpoints_changed.emit() 174 175 def toggle_breakpoint_enabled(self, line: int): 176 """The breakpoint on ``line`` disabled (kept, hollow), or enabled again 177 (⌘/Ctrl-click, Ctrl+F9); none there: one set.""" 178 if not self.debuggable: 179 return 180 if line not in self.breakpoint_lines(): 181 self.toggle_breakpoint(line) 182 return 183 self.set_breakpoints_enabled([line], line in self.disabled_breakpoints()) 184 185 def set_breakpoints_enabled(self, lines: list[int], enabled: bool): 186 """These lines' breakpoints enabled, or disabled (Debug → Disable All Breakpoints).""" 187 if not self.debuggable: 188 return 189 wanted = set(lines) 190 changed = [(cursor, enabled if cursor.blockNumber() + 1 in wanted else on) for cursor, on in self._breakpoints] 191 if changed != self._breakpoints: 192 self._breakpoints = changed 193 self.gutter.update() 194 self.breakpoints_changed.emit() 195 196 def set_breakpoint_problems(self, problems: dict[int, str]): 197 """Why the debugger couldn't place some of them (line -> why): drawn grey.""" 198 if problems != self.breakpoint_problems: 199 self.breakpoint_problems = dict(problems) 200 self.gutter.update() 201 202 def breakpoint_tip(self, line: int) -> str: 203 if line == self.execution_line: 204 return "Stopped here (the debugger)" 205 modifier = "⌘" if sys.platform == "darwin" else "Ctrl" 206 if line in self.disabled_breakpoints(): 207 return f"Breakpoint, disabled ({modifier}-click to enable it; click to remove it)" 208 if line in self.breakpoint_problems: 209 return f"Breakpoint not placed: {self.breakpoint_problems[line]}" 210 if line in self.breakpoint_lines(): 211 return f"Breakpoint (click to remove it; {modifier}-click to disable it)" 212 return "Click to set a breakpoint (F9)" if line else "" 213 214 def set_execution_line(self, line: int | None): 215 """Where the debugger stopped, in this file (None: not here): an arrow 216 in the gutter, and the line tinted.""" 217 if line != self.execution_line: 218 self.execution_line = line 219 self.gutter.update() 220 self.update_extra_selections() 221 222 def _gremlin_column(self) -> int: 223 return GREMLIN_WIDTH if self.gremlins else 0 224 225 def _coverage_column(self) -> int: 226 return COVERAGE_WIDTH if self.coverage and self.show_coverage else 0 227 228 def set_coverage(self, lines: dict[int, int] | None, branches: dict[int, tuple[int, int]] | None = None): 229 """The last test run's coverage of this file: line -> times run (lines 230 with code only), shown as green and red bars in the gutter (amber: it 231 ran, but not all of its ``branches``: line -> (taken, total)).""" 232 lines, branches = dict(lines or {}), dict(branches or {}) if lines else {} 233 if lines == self.coverage and branches == self.coverage_branches: 234 return 235 self.coverage, self.coverage_branches = lines, branches 236 self.refresh_gutter() 237 238 def coverage_state(self, line: int) -> str | None: 239 """ "ran", "partial" (ran; not every branch taken), "missed", or None 240 (no code there, or no coverage).""" 241 if line not in self.coverage: 242 return None 243 if not self.coverage[line]: 244 return "missed" 245 taken, total = self.coverage_branches.get(line, (0, 0)) 246 return "partial" if taken < total else "ran" 247 248 def missed_starts(self) -> list[int]: 249 """The first line of each stretch of lines the tests didn't run 250 (lines without code in between don't end a stretch).""" 251 starts, previous = [], None 252 for line in sorted(self.coverage): 253 missed = self.coverage[line] == 0 254 if missed and previous is not True: 255 starts.append(line) 256 previous = missed 257 return starts 258 259 def go_to_missed(self, forward: bool = True) -> bool: 260 """Edit → Go To → Next / Previous Missed Line: to the next stretch of lines 261 the tests didn't run (around to the start at the end); False if 262 there's none.""" 263 starts = self.missed_starts() 264 if not starts: 265 return False 266 here = self.textCursor().blockNumber() + 1 267 if forward: 268 target = next((n for n in starts if n > here), starts[0]) 269 else: 270 # (From inside a stretch, the one before it: not its own start.) 271 current = max((n for n in starts if n <= here), default=None) 272 target = next((n for n in reversed(starts) if n < (current if current is not None else here)), 273 starts[-1]) 274 self.go_to_line(target) 275 return True 276 277 def refresh_gutter(self): 278 """The gutter again, at its width now (a column shown or hidden), and 279 the lines' tints (missed lines).""" 280 self.update_gutter_width() 281 self.gutter.setGeometry(QRect(self.contentsRect().left(), self.contentsRect().top(), 282 self.gutter_width(), self.contentsRect().height())) 283 self.gutter.update() 284 self.update_extra_selections() 285 self._update_minimap() 286 287 def coverage_note(self, line: int) -> str: 288 """The gutter's tooltip for a coverage bar.""" 289 if line not in self.coverage: 290 return "" 291 count = self.coverage[line] 292 if not count: 293 return "Not run by the tests" 294 note = f"Run {count:,} time{'s' * (count != 1)} by the tests" 295 taken, total = self.coverage_branches.get(line, (0, 0)) 296 if total: 297 note += f" · {taken} of {total} branch{'es' * (total != 1)} taken" 298 return note 299 300 def set_gremlins(self, found: list[gremlins_mod.Gremlin]): 301 """The file's gremlins (non-ASCII and invisible characters): a gremlin 302 in the gutter on their lines, and the characters tinted.""" 303 by_line: dict[int, list[gremlins_mod.Gremlin]] = {} 304 for g in found: 305 by_line.setdefault(g.line, []).append(g) 306 if by_line == self.gremlins: 307 return 308 self.gremlins = by_line 309 self.update_gutter_width() 310 self.gutter.setGeometry(QRect(self.contentsRect().left(), self.contentsRect().top(), 311 self.gutter_width(), self.contentsRect().height())) 312 self.update_extra_selections() 313 self.gutter.update() 314 315 def gremlin_notes(self, line: int) -> list[str]: 316 """The gremlins on a line, described (for the gutter's tooltip).""" 317 return [f"Gremlin at column {g.column + 1}: {g.message} ({g.code})" for g in self.gremlins.get(line, [])] 318 319 def update_gutter_width(self, *_): 320 # (Called at every repaint of the whole view: recoloring a big file 321 # repaints it once per line. Worked out again only when the number of 322 # digits, the font or the columns may have changed.) 323 key = (len(str(self.blockCount())), self.font().key(), self._coverage_column(), self._gremlin_column(), 324 self._breakpoint_column()) 325 if key == getattr(self, "_gutter_key", None): 326 return 327 self._gutter_key = key 328 width = self.gutter_width() 329 if width != self.viewportMargins().left(): 330 self.setViewportMargins(width, 0, 0, 0) 331 332 def _update_gutter(self, rect, dy): 333 if dy: 334 self.gutter.scroll(0, dy) 335 else: 336 self.gutter.update(0, rect.y(), self.gutter.width(), rect.height()) 337 if rect.contains(self.viewport().rect()): 338 self.update_gutter_width() 339 340 def paint_gutter(self, event): 341 pal = self.palette() 342 painter = QPainter(self.gutter) 343 base = pal.color(QPalette.Base) 344 gutter_bg = base.darker(104) if base.lightness() >= 128 else base.lighter(118) 345 painter.fillRect(event.rect(), gutter_bg) 346 text = pal.color(QPalette.Text) 347 muted = QColor(text) 348 muted.setAlpha(110) 349 current = self.textCursor().blockNumber() 350 block = self.firstVisibleBlock() 351 number = block.blockNumber() 352 top = round(self.blockBoundingGeometry(block).translated(self.contentOffset()).top()) 353 bottom = top + round(self.blockBoundingRect(block).height()) 354 height = self.fontMetrics().height() 355 width = self.gutter.width() 356 painter.setRenderHint(QPainter.Antialiasing) 357 origin = self._breakpoint_column() # (the breakpoints, at the left edge: the rest after them) 358 breakpoints = set(self.breakpoint_lines()) if origin else set() 359 disabled = set(self.disabled_breakpoints()) if origin else set() 360 while block.isValid() and top <= event.rect().bottom(): 361 if block.isVisible() and bottom >= event.rect().top(): 362 line = number + 1 363 if origin: 364 self._paint_breakpoint(painter, line, line in breakpoints, line in disabled, top, height) 365 left = self._coverage_column() 366 state = self.coverage_state(line) if left else None 367 if state: # (ran: green; ran, not every branch: amber; didn't: red) 368 painter.setPen(Qt.NoPen) 369 painter.setBrush(QColor(COVERAGE_COLORS[state])) 370 painter.drawRect(QRectF(origin, top, 3, bottom - top)) 371 left += origin 372 if line in self.gremlins: 373 size = min(GREMLIN_WIDTH - 2, height) 374 gremlins_mod.paint_gremlin(painter, QRectF(1 + left, top + (height - size) / 2, size, size)) 375 font = QFont(self.font()) 376 font.setBold(number == current) 377 painter.setFont(font) 378 painter.setPen(text if number == current else muted) 379 painter.drawText(0, top, width - 8, height, Qt.AlignRight | Qt.AlignVCenter, str(line)) 380 notes = self.markers.get(line) 381 if notes: 382 worst = worst_severity(notes) 383 painter.setPen(Qt.NoPen) 384 painter.setBrush(QColor(MARKER_COLORS.get(worst, "#868e96"))) 385 d = max(6, height // 2) 386 painter.drawEllipse(4 + left + self._gremlin_column(), top + (height - d) // 2, d, d) 387 # Change bar (full line height) at the edge next to the text. 388 change = self.change_marks.get(line) 389 if change: 390 painter.setPen(Qt.NoPen) 391 painter.setBrush(QColor(CHANGE_COLORS[change])) 392 painter.drawRect(QRectF(width - 4, top, 3, bottom - top)) 393 # Deleted lines: a small triangle on the boundary above this 394 # line (and below the last one, for a deletion at the end). 395 for deleted_at, y in ((line, top), (line + 1, bottom)): 396 if deleted_at == line + 1 and block.next().isValid(): 397 continue 398 if deleted_at in self.deletions: 399 kind, _count = self.deletions[deleted_at] 400 painter.setPen(Qt.NoPen) 401 painter.setBrush(QColor(CHANGE_COLORS[kind])) 402 painter.drawPolygon(QPolygonF([QPointF(width - 7, y - 4.5), QPointF(width - 1, y), 403 QPointF(width - 7, y + 4.5)])) 404 block = block.next() 405 top = bottom 406 bottom = top + round(self.blockBoundingRect(block).height()) 407 number += 1 408 painter.end() 409 410 def _paint_breakpoint(self, painter: QPainter, line: int, on: bool, disabled: bool, top: int, height: int): 411 """A line's breakpoint (a red dot; hollow: disabled; grey: the debugger 412 couldn't place it) and, where the program is stopped, an arrow over it.""" 413 d = min(BREAKPOINT_WIDTH - 5, height - 4) 414 x, y = (BREAKPOINT_WIDTH - d) / 2, top + (height - d) / 2 415 if on: 416 color = QColor(BREAKPOINT_COLOR) 417 if disabled: 418 painter.setPen(QPen(color, 1.5)) 419 painter.setBrush(Qt.NoBrush) 420 else: 421 painter.setPen(Qt.NoPen) 422 painter.setBrush(QColor(BREAKPOINT_UNPLACED) if line in self.breakpoint_problems else color) 423 painter.drawEllipse(QRectF(x, y, d, d)) 424 if line == self.execution_line: 425 painter.setPen(QPen(QColor(EXECUTION_COLOR).darker(140), 1)) 426 painter.setBrush(QColor(EXECUTION_COLOR)) 427 mid = top + height / 2 428 painter.drawPolygon(QPolygonF([QPointF(1.5, mid - d / 2), QPointF(1.5 + d * 0.55, mid - d / 2), 429 QPointF(BREAKPOINT_WIDTH - 1.5, mid), 430 QPointF(1.5 + d * 0.55, mid + d / 2), QPointF(1.5, mid + d / 2)])) 431 432 def set_change_marks(self, marks: dict[int, str], deletions: dict[int, tuple[str, int]]): 433 if marks != self.change_marks or deletions != self.deletions: 434 self.change_marks, self.deletions = marks, deletions 435 self.gutter.update() 436 self._update_minimap() 437 438 def line_at_y(self, y: int) -> int: 439 block = self.firstVisibleBlock() 440 top = self.blockBoundingGeometry(block).translated(self.contentOffset()).top() 441 while block.isValid(): 442 bottom = top + self.blockBoundingRect(block).height() 443 if top <= y < bottom: 444 return block.blockNumber() + 1 445 block = block.next() 446 top = bottom 447 return 0 448 449 def set_markers(self, markers: dict[int, list[tuple[str, str]]]): 450 self.markers = markers 451 self.update_extra_selections() 452 self.gutter.update() 453 self._update_minimap()
34class LineNumberArea(QWidget): 35 """The gutter: line numbers plus warning/error markers.""" 36 37 def __init__(self, editor: CodeEditor): 38 super().__init__(editor) 39 self.editor = editor 40 self.setMouseTracking(True) 41 42 def sizeHint(self): 43 return QSize(self.editor.gutter_width(), 0) 44 45 def paintEvent(self, event): 46 self.editor.paint_gutter(event) 47 48 def event(self, event): 49 if event.type() == event.Type.ToolTip: 50 line = self.editor.line_at_y(event.pos().y()) 51 if event.pos().x() < self.editor._breakpoint_column(): 52 tip = self.editor.breakpoint_tip(line) 53 if tip: 54 QToolTip.showText(event.globalPos(), tip, self) 55 else: 56 QToolTip.hideText() 57 return True 58 origin = self.editor._breakpoint_column() 59 if origin <= event.pos().x() < origin + self.editor._coverage_column() and self.editor.coverage_note(line): 60 QToolTip.showText(event.globalPos(), self.editor.coverage_note(line), self) 61 return True 62 change_tip = self._change_tip(event.pos(), line) 63 if change_tip: # over a change bar or a deletion marker 64 QToolTip.showText(event.globalPos(), change_tip, self) 65 return True 66 notes = self.editor.markers.get(line) 67 # (Spelling has no gutter marks: its notes are on the words only.) 68 issues = [i for i in self.editor.lint_issues if i.line == line and i.severity != "spelling"] 69 popup = self.editor.hover_popup 70 if issues: # the same card as hovering the text (with its fix links) 71 if not (popup.isVisible() and popup.anchor == ("gutter", line)): 72 # (fortitude's and fortls's notes are the card's issues already) 73 others = [m for severity, m in notes or [] if severity not in hover_card.LINT_COLORS] 74 popup.show_at(event.globalPos(), self.editor.lint_card_html(issues, others), ("gutter", line)) 75 QToolTip.hideText() 76 elif notes or line in self.editor.gremlins: 77 lines = [m for _, m in notes or []] + self.editor.gremlin_notes(line) 78 QToolTip.showText(event.globalPos(), "\n".join(lines), self) 79 else: 80 QToolTip.hideText() 81 return True 82 return super().event(event) 83 84 def _change_tip(self, pos, line: int) -> str: 85 """Tooltip for the change bars at the gutter's right edge.""" 86 if pos.x() < self.width() - 9: 87 return "" 88 editor = self.editor 89 block = editor.document().findBlockByNumber(line - 1) 90 if block.isValid(): 91 top = editor.blockBoundingGeometry(block).translated(editor.contentOffset()).top() 92 bottom = top + editor.blockBoundingRect(block).height() 93 for deleted_at, y in ((line, top), (line + 1, bottom)): 94 if deleted_at in editor.deletions and abs(pos.y() - y) <= 5: 95 kind, count = editor.deletions[deleted_at] 96 what = f"{count} line{'s' * (count != 1)} deleted here" 97 return what + (" — not saved yet" if kind == "unsaved" else " (saved)") 98 change = editor.change_marks.get(line) 99 if change == "unsaved": 100 return "Changed — not saved yet" 101 if change == "saved": 102 return "Changed since the file was opened (saved)" 103 return "" 104 105 def mouseMoveEvent(self, event): 106 popup = self.editor.hover_popup 107 if popup.isVisible() and popup.anchor and popup.anchor[0] == "gutter": 108 if popup.anchor == ("gutter", self.editor.line_at_y(event.position().toPoint().y())): 109 popup.keep() 110 else: 111 popup.schedule_hide() 112 super().mouseMoveEvent(event) 113 114 def leaveEvent(self, event): 115 self.editor.hover_popup.schedule_hide() # (unless the mouse went into it) 116 super().leaveEvent(event) 117 118 def mousePressEvent(self, event): 119 # Clicking the breakpoint column sets or clears one; a number selects that line. 120 line = self.editor.line_at_y(event.pos().y()) 121 if line and event.pos().x() < self.editor._breakpoint_column(): 122 if event.modifiers() & Qt.ControlModifier: # (⌘ on macOS: disable or enable it) 123 self.editor.toggle_breakpoint_enabled(line) 124 else: 125 self.editor.toggle_breakpoint(line) 126 elif line: 127 self.editor.go_to_line(line, select=True)
The gutter: line numbers plus warning/error markers.
37 def __init__(self, editor: CodeEditor): 38 super().__init__(editor) 39 self.editor = editor 40 self.setMouseTracking(True)
__init__(self, /, parent: PySide6.QtWidgets.QWidget | None = None, f: PySide6.QtCore.Qt.WindowType = Default(Qt.WindowFlags), *, modal: bool | None = None, windowModality: PySide6.QtCore.Qt.WindowModality | None = None, enabled: bool | None = None, geometry: PySide6.QtCore.QRect | None = None, frameGeometry: PySide6.QtCore.QRect | None = None, normalGeometry: PySide6.QtCore.QRect | None = None, x: int | None = None, y: int | None = None, pos: PySide6.QtCore.QPoint | None = None, frameSize: PySide6.QtCore.QSize | None = None, size: PySide6.QtCore.QSize | None = None, width: int | None = None, height: int | None = None, rect: PySide6.QtCore.QRect | None = None, childrenRect: PySide6.QtCore.QRect | None = None, childrenRegion: PySide6.QtGui.QRegion | None = None, sizePolicy: PySide6.QtWidgets.QSizePolicy | None = None, minimumSize: PySide6.QtCore.QSize | None = None, maximumSize: PySide6.QtCore.QSize | None = None, minimumWidth: int | None = None, minimumHeight: int | None = None, maximumWidth: int | None = None, maximumHeight: int | None = None, sizeIncrement: PySide6.QtCore.QSize | None = None, baseSize: PySide6.QtCore.QSize | None = None, palette: PySide6.QtGui.QPalette | None = None, font: PySide6.QtGui.QFont | None = None, cursor: PySide6.QtGui.QCursor | None = None, mouseTracking: bool | None = None, tabletTracking: bool | None = None, isActiveWindow: bool | None = None, focusPolicy: PySide6.QtCore.Qt.FocusPolicy | None = None, focus: bool | None = None, contextMenuPolicy: PySide6.QtCore.Qt.ContextMenuPolicy | None = None, updatesEnabled: bool | None = None, visible: bool | None = None, minimized: bool | None = None, maximized: bool | None = None, fullScreen: bool | None = None, sizeHint: PySide6.QtCore.QSize | None = None, minimumSizeHint: PySide6.QtCore.QSize | None = None, acceptDrops: bool | None = None, windowTitle: str | None = None, windowIcon: PySide6.QtGui.QIcon | None = None, windowIconText: str | None = None, windowOpacity: float | None = None, windowModified: bool | None = None, toolTip: str | None = None, toolTipDuration: int | None = None, statusTip: str | None = None, whatsThis: str | None = None, accessibleName: str | None = None, accessibleDescription: str | None = None, accessibleIdentifier: str | None = None, layoutDirection: PySide6.QtCore.Qt.LayoutDirection | None = None, autoFillBackground: bool | None = None, styleSheet: str | None = None, locale: PySide6.QtCore.QLocale | None = None, windowFilePath: str | None = None, inputMethodHints: PySide6.QtCore.Qt.InputMethodHint | None = None) -> None
Initialize self. See help(type(self)) for accurate signature.
48 def event(self, event): 49 if event.type() == event.Type.ToolTip: 50 line = self.editor.line_at_y(event.pos().y()) 51 if event.pos().x() < self.editor._breakpoint_column(): 52 tip = self.editor.breakpoint_tip(line) 53 if tip: 54 QToolTip.showText(event.globalPos(), tip, self) 55 else: 56 QToolTip.hideText() 57 return True 58 origin = self.editor._breakpoint_column() 59 if origin <= event.pos().x() < origin + self.editor._coverage_column() and self.editor.coverage_note(line): 60 QToolTip.showText(event.globalPos(), self.editor.coverage_note(line), self) 61 return True 62 change_tip = self._change_tip(event.pos(), line) 63 if change_tip: # over a change bar or a deletion marker 64 QToolTip.showText(event.globalPos(), change_tip, self) 65 return True 66 notes = self.editor.markers.get(line) 67 # (Spelling has no gutter marks: its notes are on the words only.) 68 issues = [i for i in self.editor.lint_issues if i.line == line and i.severity != "spelling"] 69 popup = self.editor.hover_popup 70 if issues: # the same card as hovering the text (with its fix links) 71 if not (popup.isVisible() and popup.anchor == ("gutter", line)): 72 # (fortitude's and fortls's notes are the card's issues already) 73 others = [m for severity, m in notes or [] if severity not in hover_card.LINT_COLORS] 74 popup.show_at(event.globalPos(), self.editor.lint_card_html(issues, others), ("gutter", line)) 75 QToolTip.hideText() 76 elif notes or line in self.editor.gremlins: 77 lines = [m for _, m in notes or []] + self.editor.gremlin_notes(line) 78 QToolTip.showText(event.globalPos(), "\n".join(lines), self) 79 else: 80 QToolTip.hideText() 81 return True 82 return super().event(event)
event(self, event: PySide6.QtCore.QEvent, /) -> bool
105 def mouseMoveEvent(self, event): 106 popup = self.editor.hover_popup 107 if popup.isVisible() and popup.anchor and popup.anchor[0] == "gutter": 108 if popup.anchor == ("gutter", self.editor.line_at_y(event.position().toPoint().y())): 109 popup.keep() 110 else: 111 popup.schedule_hide() 112 super().mouseMoveEvent(event)
mouseMoveEvent(self, event: PySide6.QtGui.QMouseEvent, /) -> None
114 def leaveEvent(self, event): 115 self.editor.hover_popup.schedule_hide() # (unless the mouse went into it) 116 super().leaveEvent(event)
leaveEvent(self, event: PySide6.QtCore.QEvent, /) -> None
118 def mousePressEvent(self, event): 119 # Clicking the breakpoint column sets or clears one; a number selects that line. 120 line = self.editor.line_at_y(event.pos().y()) 121 if line and event.pos().x() < self.editor._breakpoint_column(): 122 if event.modifiers() & Qt.ControlModifier: # (⌘ on macOS: disable or enable it) 123 self.editor.toggle_breakpoint_enabled(line) 124 else: 125 self.editor.toggle_breakpoint(line) 126 elif line: 127 self.editor.go_to_line(line, select=True)
mousePressEvent(self, event: PySide6.QtGui.QMouseEvent, /) -> None
130class EditorGutterMixin: 131 """The editor's gutter (a part of `vibefpmgui.code_editor.CodeEditor`).""" 132 133 def gutter_width(self) -> int: 134 digits = max(3, len(str(max(1, self.blockCount())))) 135 return (22 + self.fontMetrics().horizontalAdvance("9") * digits + self._coverage_column() 136 + self._gremlin_column() + self._breakpoint_column()) 137 138 def _breakpoint_column(self) -> int: 139 return BREAKPOINT_WIDTH if self.debuggable else 0 140 141 # ------------------------------------------------------------ breakpoints 142 def breakpoint_lines(self) -> list[int]: 143 """The lines (1-based) with a breakpoint, enabled or not, as they are 144 now (they move with edits).""" 145 return sorted({cursor.blockNumber() + 1 for cursor, _on in self._breakpoints}) 146 147 def disabled_breakpoints(self) -> list[int]: 148 """The lines whose breakpoint is disabled (kept, drawn hollow; the debugger doesn't stop there).""" 149 enabled = {cursor.blockNumber() + 1 for cursor, on in self._breakpoints if on} 150 return sorted({cursor.blockNumber() + 1 for cursor, on in self._breakpoints if not on} - enabled) 151 152 def enabled_breakpoints(self) -> list[int]: 153 disabled = set(self.disabled_breakpoints()) 154 return [n for n in self.breakpoint_lines() if n not in disabled] 155 156 def set_breakpoint_lines(self, lines: list[int], disabled: list[int] | None = None): 157 """Breakpoints on these lines (those past the end: dropped); ``disabled``: these of them, disabled.""" 158 doc = self.document() 159 off = set(disabled or ()) 160 self._breakpoints = [] 161 for line in sorted(set(lines)): 162 block = doc.findBlockByNumber(line - 1) 163 if block.isValid(): 164 self._breakpoints.append((QTextCursor(block), line not in off)) 165 self.breakpoint_problems = {} 166 self.gutter.update() 167 168 def toggle_breakpoint(self, line: int): 169 """A breakpoint on ``line`` set, or removed (gutter click, F9).""" 170 if not self.debuggable: 171 return 172 lines, off = self.breakpoint_lines(), self.disabled_breakpoints() 173 self.set_breakpoint_lines([n for n in lines if n != line] if line in lines else [*lines, line], off) 174 self.breakpoints_changed.emit() 175 176 def toggle_breakpoint_enabled(self, line: int): 177 """The breakpoint on ``line`` disabled (kept, hollow), or enabled again 178 (⌘/Ctrl-click, Ctrl+F9); none there: one set.""" 179 if not self.debuggable: 180 return 181 if line not in self.breakpoint_lines(): 182 self.toggle_breakpoint(line) 183 return 184 self.set_breakpoints_enabled([line], line in self.disabled_breakpoints()) 185 186 def set_breakpoints_enabled(self, lines: list[int], enabled: bool): 187 """These lines' breakpoints enabled, or disabled (Debug → Disable All Breakpoints).""" 188 if not self.debuggable: 189 return 190 wanted = set(lines) 191 changed = [(cursor, enabled if cursor.blockNumber() + 1 in wanted else on) for cursor, on in self._breakpoints] 192 if changed != self._breakpoints: 193 self._breakpoints = changed 194 self.gutter.update() 195 self.breakpoints_changed.emit() 196 197 def set_breakpoint_problems(self, problems: dict[int, str]): 198 """Why the debugger couldn't place some of them (line -> why): drawn grey.""" 199 if problems != self.breakpoint_problems: 200 self.breakpoint_problems = dict(problems) 201 self.gutter.update() 202 203 def breakpoint_tip(self, line: int) -> str: 204 if line == self.execution_line: 205 return "Stopped here (the debugger)" 206 modifier = "⌘" if sys.platform == "darwin" else "Ctrl" 207 if line in self.disabled_breakpoints(): 208 return f"Breakpoint, disabled ({modifier}-click to enable it; click to remove it)" 209 if line in self.breakpoint_problems: 210 return f"Breakpoint not placed: {self.breakpoint_problems[line]}" 211 if line in self.breakpoint_lines(): 212 return f"Breakpoint (click to remove it; {modifier}-click to disable it)" 213 return "Click to set a breakpoint (F9)" if line else "" 214 215 def set_execution_line(self, line: int | None): 216 """Where the debugger stopped, in this file (None: not here): an arrow 217 in the gutter, and the line tinted.""" 218 if line != self.execution_line: 219 self.execution_line = line 220 self.gutter.update() 221 self.update_extra_selections() 222 223 def _gremlin_column(self) -> int: 224 return GREMLIN_WIDTH if self.gremlins else 0 225 226 def _coverage_column(self) -> int: 227 return COVERAGE_WIDTH if self.coverage and self.show_coverage else 0 228 229 def set_coverage(self, lines: dict[int, int] | None, branches: dict[int, tuple[int, int]] | None = None): 230 """The last test run's coverage of this file: line -> times run (lines 231 with code only), shown as green and red bars in the gutter (amber: it 232 ran, but not all of its ``branches``: line -> (taken, total)).""" 233 lines, branches = dict(lines or {}), dict(branches or {}) if lines else {} 234 if lines == self.coverage and branches == self.coverage_branches: 235 return 236 self.coverage, self.coverage_branches = lines, branches 237 self.refresh_gutter() 238 239 def coverage_state(self, line: int) -> str | None: 240 """ "ran", "partial" (ran; not every branch taken), "missed", or None 241 (no code there, or no coverage).""" 242 if line not in self.coverage: 243 return None 244 if not self.coverage[line]: 245 return "missed" 246 taken, total = self.coverage_branches.get(line, (0, 0)) 247 return "partial" if taken < total else "ran" 248 249 def missed_starts(self) -> list[int]: 250 """The first line of each stretch of lines the tests didn't run 251 (lines without code in between don't end a stretch).""" 252 starts, previous = [], None 253 for line in sorted(self.coverage): 254 missed = self.coverage[line] == 0 255 if missed and previous is not True: 256 starts.append(line) 257 previous = missed 258 return starts 259 260 def go_to_missed(self, forward: bool = True) -> bool: 261 """Edit → Go To → Next / Previous Missed Line: to the next stretch of lines 262 the tests didn't run (around to the start at the end); False if 263 there's none.""" 264 starts = self.missed_starts() 265 if not starts: 266 return False 267 here = self.textCursor().blockNumber() + 1 268 if forward: 269 target = next((n for n in starts if n > here), starts[0]) 270 else: 271 # (From inside a stretch, the one before it: not its own start.) 272 current = max((n for n in starts if n <= here), default=None) 273 target = next((n for n in reversed(starts) if n < (current if current is not None else here)), 274 starts[-1]) 275 self.go_to_line(target) 276 return True 277 278 def refresh_gutter(self): 279 """The gutter again, at its width now (a column shown or hidden), and 280 the lines' tints (missed lines).""" 281 self.update_gutter_width() 282 self.gutter.setGeometry(QRect(self.contentsRect().left(), self.contentsRect().top(), 283 self.gutter_width(), self.contentsRect().height())) 284 self.gutter.update() 285 self.update_extra_selections() 286 self._update_minimap() 287 288 def coverage_note(self, line: int) -> str: 289 """The gutter's tooltip for a coverage bar.""" 290 if line not in self.coverage: 291 return "" 292 count = self.coverage[line] 293 if not count: 294 return "Not run by the tests" 295 note = f"Run {count:,} time{'s' * (count != 1)} by the tests" 296 taken, total = self.coverage_branches.get(line, (0, 0)) 297 if total: 298 note += f" · {taken} of {total} branch{'es' * (total != 1)} taken" 299 return note 300 301 def set_gremlins(self, found: list[gremlins_mod.Gremlin]): 302 """The file's gremlins (non-ASCII and invisible characters): a gremlin 303 in the gutter on their lines, and the characters tinted.""" 304 by_line: dict[int, list[gremlins_mod.Gremlin]] = {} 305 for g in found: 306 by_line.setdefault(g.line, []).append(g) 307 if by_line == self.gremlins: 308 return 309 self.gremlins = by_line 310 self.update_gutter_width() 311 self.gutter.setGeometry(QRect(self.contentsRect().left(), self.contentsRect().top(), 312 self.gutter_width(), self.contentsRect().height())) 313 self.update_extra_selections() 314 self.gutter.update() 315 316 def gremlin_notes(self, line: int) -> list[str]: 317 """The gremlins on a line, described (for the gutter's tooltip).""" 318 return [f"Gremlin at column {g.column + 1}: {g.message} ({g.code})" for g in self.gremlins.get(line, [])] 319 320 def update_gutter_width(self, *_): 321 # (Called at every repaint of the whole view: recoloring a big file 322 # repaints it once per line. Worked out again only when the number of 323 # digits, the font or the columns may have changed.) 324 key = (len(str(self.blockCount())), self.font().key(), self._coverage_column(), self._gremlin_column(), 325 self._breakpoint_column()) 326 if key == getattr(self, "_gutter_key", None): 327 return 328 self._gutter_key = key 329 width = self.gutter_width() 330 if width != self.viewportMargins().left(): 331 self.setViewportMargins(width, 0, 0, 0) 332 333 def _update_gutter(self, rect, dy): 334 if dy: 335 self.gutter.scroll(0, dy) 336 else: 337 self.gutter.update(0, rect.y(), self.gutter.width(), rect.height()) 338 if rect.contains(self.viewport().rect()): 339 self.update_gutter_width() 340 341 def paint_gutter(self, event): 342 pal = self.palette() 343 painter = QPainter(self.gutter) 344 base = pal.color(QPalette.Base) 345 gutter_bg = base.darker(104) if base.lightness() >= 128 else base.lighter(118) 346 painter.fillRect(event.rect(), gutter_bg) 347 text = pal.color(QPalette.Text) 348 muted = QColor(text) 349 muted.setAlpha(110) 350 current = self.textCursor().blockNumber() 351 block = self.firstVisibleBlock() 352 number = block.blockNumber() 353 top = round(self.blockBoundingGeometry(block).translated(self.contentOffset()).top()) 354 bottom = top + round(self.blockBoundingRect(block).height()) 355 height = self.fontMetrics().height() 356 width = self.gutter.width() 357 painter.setRenderHint(QPainter.Antialiasing) 358 origin = self._breakpoint_column() # (the breakpoints, at the left edge: the rest after them) 359 breakpoints = set(self.breakpoint_lines()) if origin else set() 360 disabled = set(self.disabled_breakpoints()) if origin else set() 361 while block.isValid() and top <= event.rect().bottom(): 362 if block.isVisible() and bottom >= event.rect().top(): 363 line = number + 1 364 if origin: 365 self._paint_breakpoint(painter, line, line in breakpoints, line in disabled, top, height) 366 left = self._coverage_column() 367 state = self.coverage_state(line) if left else None 368 if state: # (ran: green; ran, not every branch: amber; didn't: red) 369 painter.setPen(Qt.NoPen) 370 painter.setBrush(QColor(COVERAGE_COLORS[state])) 371 painter.drawRect(QRectF(origin, top, 3, bottom - top)) 372 left += origin 373 if line in self.gremlins: 374 size = min(GREMLIN_WIDTH - 2, height) 375 gremlins_mod.paint_gremlin(painter, QRectF(1 + left, top + (height - size) / 2, size, size)) 376 font = QFont(self.font()) 377 font.setBold(number == current) 378 painter.setFont(font) 379 painter.setPen(text if number == current else muted) 380 painter.drawText(0, top, width - 8, height, Qt.AlignRight | Qt.AlignVCenter, str(line)) 381 notes = self.markers.get(line) 382 if notes: 383 worst = worst_severity(notes) 384 painter.setPen(Qt.NoPen) 385 painter.setBrush(QColor(MARKER_COLORS.get(worst, "#868e96"))) 386 d = max(6, height // 2) 387 painter.drawEllipse(4 + left + self._gremlin_column(), top + (height - d) // 2, d, d) 388 # Change bar (full line height) at the edge next to the text. 389 change = self.change_marks.get(line) 390 if change: 391 painter.setPen(Qt.NoPen) 392 painter.setBrush(QColor(CHANGE_COLORS[change])) 393 painter.drawRect(QRectF(width - 4, top, 3, bottom - top)) 394 # Deleted lines: a small triangle on the boundary above this 395 # line (and below the last one, for a deletion at the end). 396 for deleted_at, y in ((line, top), (line + 1, bottom)): 397 if deleted_at == line + 1 and block.next().isValid(): 398 continue 399 if deleted_at in self.deletions: 400 kind, _count = self.deletions[deleted_at] 401 painter.setPen(Qt.NoPen) 402 painter.setBrush(QColor(CHANGE_COLORS[kind])) 403 painter.drawPolygon(QPolygonF([QPointF(width - 7, y - 4.5), QPointF(width - 1, y), 404 QPointF(width - 7, y + 4.5)])) 405 block = block.next() 406 top = bottom 407 bottom = top + round(self.blockBoundingRect(block).height()) 408 number += 1 409 painter.end() 410 411 def _paint_breakpoint(self, painter: QPainter, line: int, on: bool, disabled: bool, top: int, height: int): 412 """A line's breakpoint (a red dot; hollow: disabled; grey: the debugger 413 couldn't place it) and, where the program is stopped, an arrow over it.""" 414 d = min(BREAKPOINT_WIDTH - 5, height - 4) 415 x, y = (BREAKPOINT_WIDTH - d) / 2, top + (height - d) / 2 416 if on: 417 color = QColor(BREAKPOINT_COLOR) 418 if disabled: 419 painter.setPen(QPen(color, 1.5)) 420 painter.setBrush(Qt.NoBrush) 421 else: 422 painter.setPen(Qt.NoPen) 423 painter.setBrush(QColor(BREAKPOINT_UNPLACED) if line in self.breakpoint_problems else color) 424 painter.drawEllipse(QRectF(x, y, d, d)) 425 if line == self.execution_line: 426 painter.setPen(QPen(QColor(EXECUTION_COLOR).darker(140), 1)) 427 painter.setBrush(QColor(EXECUTION_COLOR)) 428 mid = top + height / 2 429 painter.drawPolygon(QPolygonF([QPointF(1.5, mid - d / 2), QPointF(1.5 + d * 0.55, mid - d / 2), 430 QPointF(BREAKPOINT_WIDTH - 1.5, mid), 431 QPointF(1.5 + d * 0.55, mid + d / 2), QPointF(1.5, mid + d / 2)])) 432 433 def set_change_marks(self, marks: dict[int, str], deletions: dict[int, tuple[str, int]]): 434 if marks != self.change_marks or deletions != self.deletions: 435 self.change_marks, self.deletions = marks, deletions 436 self.gutter.update() 437 self._update_minimap() 438 439 def line_at_y(self, y: int) -> int: 440 block = self.firstVisibleBlock() 441 top = self.blockBoundingGeometry(block).translated(self.contentOffset()).top() 442 while block.isValid(): 443 bottom = top + self.blockBoundingRect(block).height() 444 if top <= y < bottom: 445 return block.blockNumber() + 1 446 block = block.next() 447 top = bottom 448 return 0 449 450 def set_markers(self, markers: dict[int, list[tuple[str, str]]]): 451 self.markers = markers 452 self.update_extra_selections() 453 self.gutter.update() 454 self._update_minimap()
The editor's gutter (a part of vibefpmgui.code_editor.CodeEditor).
142 def breakpoint_lines(self) -> list[int]: 143 """The lines (1-based) with a breakpoint, enabled or not, as they are 144 now (they move with edits).""" 145 return sorted({cursor.blockNumber() + 1 for cursor, _on in self._breakpoints})
The lines (1-based) with a breakpoint, enabled or not, as they are now (they move with edits).
147 def disabled_breakpoints(self) -> list[int]: 148 """The lines whose breakpoint is disabled (kept, drawn hollow; the debugger doesn't stop there).""" 149 enabled = {cursor.blockNumber() + 1 for cursor, on in self._breakpoints if on} 150 return sorted({cursor.blockNumber() + 1 for cursor, on in self._breakpoints if not on} - enabled)
The lines whose breakpoint is disabled (kept, drawn hollow; the debugger doesn't stop there).
156 def set_breakpoint_lines(self, lines: list[int], disabled: list[int] | None = None): 157 """Breakpoints on these lines (those past the end: dropped); ``disabled``: these of them, disabled.""" 158 doc = self.document() 159 off = set(disabled or ()) 160 self._breakpoints = [] 161 for line in sorted(set(lines)): 162 block = doc.findBlockByNumber(line - 1) 163 if block.isValid(): 164 self._breakpoints.append((QTextCursor(block), line not in off)) 165 self.breakpoint_problems = {} 166 self.gutter.update()
Breakpoints on these lines (those past the end: dropped); disabled: these of them, disabled.
168 def toggle_breakpoint(self, line: int): 169 """A breakpoint on ``line`` set, or removed (gutter click, F9).""" 170 if not self.debuggable: 171 return 172 lines, off = self.breakpoint_lines(), self.disabled_breakpoints() 173 self.set_breakpoint_lines([n for n in lines if n != line] if line in lines else [*lines, line], off) 174 self.breakpoints_changed.emit()
A breakpoint on line set, or removed (gutter click, F9).
176 def toggle_breakpoint_enabled(self, line: int): 177 """The breakpoint on ``line`` disabled (kept, hollow), or enabled again 178 (⌘/Ctrl-click, Ctrl+F9); none there: one set.""" 179 if not self.debuggable: 180 return 181 if line not in self.breakpoint_lines(): 182 self.toggle_breakpoint(line) 183 return 184 self.set_breakpoints_enabled([line], line in self.disabled_breakpoints())
The breakpoint on line disabled (kept, hollow), or enabled again
(⌘/Ctrl-click, Ctrl+F9); none there: one set.
186 def set_breakpoints_enabled(self, lines: list[int], enabled: bool): 187 """These lines' breakpoints enabled, or disabled (Debug → Disable All Breakpoints).""" 188 if not self.debuggable: 189 return 190 wanted = set(lines) 191 changed = [(cursor, enabled if cursor.blockNumber() + 1 in wanted else on) for cursor, on in self._breakpoints] 192 if changed != self._breakpoints: 193 self._breakpoints = changed 194 self.gutter.update() 195 self.breakpoints_changed.emit()
These lines' breakpoints enabled, or disabled (Debug → Disable All Breakpoints).
197 def set_breakpoint_problems(self, problems: dict[int, str]): 198 """Why the debugger couldn't place some of them (line -> why): drawn grey.""" 199 if problems != self.breakpoint_problems: 200 self.breakpoint_problems = dict(problems) 201 self.gutter.update()
Why the debugger couldn't place some of them (line -> why): drawn grey.
203 def breakpoint_tip(self, line: int) -> str: 204 if line == self.execution_line: 205 return "Stopped here (the debugger)" 206 modifier = "⌘" if sys.platform == "darwin" else "Ctrl" 207 if line in self.disabled_breakpoints(): 208 return f"Breakpoint, disabled ({modifier}-click to enable it; click to remove it)" 209 if line in self.breakpoint_problems: 210 return f"Breakpoint not placed: {self.breakpoint_problems[line]}" 211 if line in self.breakpoint_lines(): 212 return f"Breakpoint (click to remove it; {modifier}-click to disable it)" 213 return "Click to set a breakpoint (F9)" if line else ""
215 def set_execution_line(self, line: int | None): 216 """Where the debugger stopped, in this file (None: not here): an arrow 217 in the gutter, and the line tinted.""" 218 if line != self.execution_line: 219 self.execution_line = line 220 self.gutter.update() 221 self.update_extra_selections()
Where the debugger stopped, in this file (None: not here): an arrow in the gutter, and the line tinted.
229 def set_coverage(self, lines: dict[int, int] | None, branches: dict[int, tuple[int, int]] | None = None): 230 """The last test run's coverage of this file: line -> times run (lines 231 with code only), shown as green and red bars in the gutter (amber: it 232 ran, but not all of its ``branches``: line -> (taken, total)).""" 233 lines, branches = dict(lines or {}), dict(branches or {}) if lines else {} 234 if lines == self.coverage and branches == self.coverage_branches: 235 return 236 self.coverage, self.coverage_branches = lines, branches 237 self.refresh_gutter()
The last test run's coverage of this file: line -> times run (lines
with code only), shown as green and red bars in the gutter (amber: it
ran, but not all of its branches: line -> (taken, total)).
239 def coverage_state(self, line: int) -> str | None: 240 """ "ran", "partial" (ran; not every branch taken), "missed", or None 241 (no code there, or no coverage).""" 242 if line not in self.coverage: 243 return None 244 if not self.coverage[line]: 245 return "missed" 246 taken, total = self.coverage_branches.get(line, (0, 0)) 247 return "partial" if taken < total else "ran"
"ran", "partial" (ran; not every branch taken), "missed", or None (no code there, or no coverage).
249 def missed_starts(self) -> list[int]: 250 """The first line of each stretch of lines the tests didn't run 251 (lines without code in between don't end a stretch).""" 252 starts, previous = [], None 253 for line in sorted(self.coverage): 254 missed = self.coverage[line] == 0 255 if missed and previous is not True: 256 starts.append(line) 257 previous = missed 258 return starts
The first line of each stretch of lines the tests didn't run (lines without code in between don't end a stretch).
260 def go_to_missed(self, forward: bool = True) -> bool: 261 """Edit → Go To → Next / Previous Missed Line: to the next stretch of lines 262 the tests didn't run (around to the start at the end); False if 263 there's none.""" 264 starts = self.missed_starts() 265 if not starts: 266 return False 267 here = self.textCursor().blockNumber() + 1 268 if forward: 269 target = next((n for n in starts if n > here), starts[0]) 270 else: 271 # (From inside a stretch, the one before it: not its own start.) 272 current = max((n for n in starts if n <= here), default=None) 273 target = next((n for n in reversed(starts) if n < (current if current is not None else here)), 274 starts[-1]) 275 self.go_to_line(target) 276 return True
Edit → Go To → Next / Previous Missed Line: to the next stretch of lines the tests didn't run (around to the start at the end); False if there's none.
278 def refresh_gutter(self): 279 """The gutter again, at its width now (a column shown or hidden), and 280 the lines' tints (missed lines).""" 281 self.update_gutter_width() 282 self.gutter.setGeometry(QRect(self.contentsRect().left(), self.contentsRect().top(), 283 self.gutter_width(), self.contentsRect().height())) 284 self.gutter.update() 285 self.update_extra_selections() 286 self._update_minimap()
The gutter again, at its width now (a column shown or hidden), and the lines' tints (missed lines).
288 def coverage_note(self, line: int) -> str: 289 """The gutter's tooltip for a coverage bar.""" 290 if line not in self.coverage: 291 return "" 292 count = self.coverage[line] 293 if not count: 294 return "Not run by the tests" 295 note = f"Run {count:,} time{'s' * (count != 1)} by the tests" 296 taken, total = self.coverage_branches.get(line, (0, 0)) 297 if total: 298 note += f" · {taken} of {total} branch{'es' * (total != 1)} taken" 299 return note
The gutter's tooltip for a coverage bar.
301 def set_gremlins(self, found: list[gremlins_mod.Gremlin]): 302 """The file's gremlins (non-ASCII and invisible characters): a gremlin 303 in the gutter on their lines, and the characters tinted.""" 304 by_line: dict[int, list[gremlins_mod.Gremlin]] = {} 305 for g in found: 306 by_line.setdefault(g.line, []).append(g) 307 if by_line == self.gremlins: 308 return 309 self.gremlins = by_line 310 self.update_gutter_width() 311 self.gutter.setGeometry(QRect(self.contentsRect().left(), self.contentsRect().top(), 312 self.gutter_width(), self.contentsRect().height())) 313 self.update_extra_selections() 314 self.gutter.update()
The file's gremlins (non-ASCII and invisible characters): a gremlin in the gutter on their lines, and the characters tinted.
316 def gremlin_notes(self, line: int) -> list[str]: 317 """The gremlins on a line, described (for the gutter's tooltip).""" 318 return [f"Gremlin at column {g.column + 1}: {g.message} ({g.code})" for g in self.gremlins.get(line, [])]
The gremlins on a line, described (for the gutter's tooltip).
320 def update_gutter_width(self, *_): 321 # (Called at every repaint of the whole view: recoloring a big file 322 # repaints it once per line. Worked out again only when the number of 323 # digits, the font or the columns may have changed.) 324 key = (len(str(self.blockCount())), self.font().key(), self._coverage_column(), self._gremlin_column(), 325 self._breakpoint_column()) 326 if key == getattr(self, "_gutter_key", None): 327 return 328 self._gutter_key = key 329 width = self.gutter_width() 330 if width != self.viewportMargins().left(): 331 self.setViewportMargins(width, 0, 0, 0)
341 def paint_gutter(self, event): 342 pal = self.palette() 343 painter = QPainter(self.gutter) 344 base = pal.color(QPalette.Base) 345 gutter_bg = base.darker(104) if base.lightness() >= 128 else base.lighter(118) 346 painter.fillRect(event.rect(), gutter_bg) 347 text = pal.color(QPalette.Text) 348 muted = QColor(text) 349 muted.setAlpha(110) 350 current = self.textCursor().blockNumber() 351 block = self.firstVisibleBlock() 352 number = block.blockNumber() 353 top = round(self.blockBoundingGeometry(block).translated(self.contentOffset()).top()) 354 bottom = top + round(self.blockBoundingRect(block).height()) 355 height = self.fontMetrics().height() 356 width = self.gutter.width() 357 painter.setRenderHint(QPainter.Antialiasing) 358 origin = self._breakpoint_column() # (the breakpoints, at the left edge: the rest after them) 359 breakpoints = set(self.breakpoint_lines()) if origin else set() 360 disabled = set(self.disabled_breakpoints()) if origin else set() 361 while block.isValid() and top <= event.rect().bottom(): 362 if block.isVisible() and bottom >= event.rect().top(): 363 line = number + 1 364 if origin: 365 self._paint_breakpoint(painter, line, line in breakpoints, line in disabled, top, height) 366 left = self._coverage_column() 367 state = self.coverage_state(line) if left else None 368 if state: # (ran: green; ran, not every branch: amber; didn't: red) 369 painter.setPen(Qt.NoPen) 370 painter.setBrush(QColor(COVERAGE_COLORS[state])) 371 painter.drawRect(QRectF(origin, top, 3, bottom - top)) 372 left += origin 373 if line in self.gremlins: 374 size = min(GREMLIN_WIDTH - 2, height) 375 gremlins_mod.paint_gremlin(painter, QRectF(1 + left, top + (height - size) / 2, size, size)) 376 font = QFont(self.font()) 377 font.setBold(number == current) 378 painter.setFont(font) 379 painter.setPen(text if number == current else muted) 380 painter.drawText(0, top, width - 8, height, Qt.AlignRight | Qt.AlignVCenter, str(line)) 381 notes = self.markers.get(line) 382 if notes: 383 worst = worst_severity(notes) 384 painter.setPen(Qt.NoPen) 385 painter.setBrush(QColor(MARKER_COLORS.get(worst, "#868e96"))) 386 d = max(6, height // 2) 387 painter.drawEllipse(4 + left + self._gremlin_column(), top + (height - d) // 2, d, d) 388 # Change bar (full line height) at the edge next to the text. 389 change = self.change_marks.get(line) 390 if change: 391 painter.setPen(Qt.NoPen) 392 painter.setBrush(QColor(CHANGE_COLORS[change])) 393 painter.drawRect(QRectF(width - 4, top, 3, bottom - top)) 394 # Deleted lines: a small triangle on the boundary above this 395 # line (and below the last one, for a deletion at the end). 396 for deleted_at, y in ((line, top), (line + 1, bottom)): 397 if deleted_at == line + 1 and block.next().isValid(): 398 continue 399 if deleted_at in self.deletions: 400 kind, _count = self.deletions[deleted_at] 401 painter.setPen(Qt.NoPen) 402 painter.setBrush(QColor(CHANGE_COLORS[kind])) 403 painter.drawPolygon(QPolygonF([QPointF(width - 7, y - 4.5), QPointF(width - 1, y), 404 QPointF(width - 7, y + 4.5)])) 405 block = block.next() 406 top = bottom 407 bottom = top + round(self.blockBoundingRect(block).height()) 408 number += 1 409 painter.end()
439 def line_at_y(self, y: int) -> int: 440 block = self.firstVisibleBlock() 441 top = self.blockBoundingGeometry(block).translated(self.contentOffset()).top() 442 while block.isValid(): 443 bottom = top + self.blockBoundingRect(block).height() 444 if top <= y < bottom: 445 return block.blockNumber() + 1 446 block = block.next() 447 top = bottom 448 return 0