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()
class LineNumberArea(PySide6.QtWidgets.QWidget):
 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.

LineNumberArea(editor: vibefpmgui.code_editor.CodeEditor)
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.

editor
def sizeHint(self):
42    def sizeHint(self):
43        return QSize(self.editor.gutter_width(), 0)

sizeHint(self, /) -> PySide6.QtCore.QSize

def paintEvent(self, event):
45    def paintEvent(self, event):
46        self.editor.paint_gutter(event)

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

def event(self, event):
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

def mouseMoveEvent(self, event):
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

def leaveEvent(self, event):
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

def mousePressEvent(self, event):
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

staticMetaObject = PySide6.QtCore.QMetaObject("LineNumberArea" inherits "QWidget": )
class EditorGutterMixin:
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).

def gutter_width(self) -> int:
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())
def breakpoint_lines(self) -> list[int]:
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).

def disabled_breakpoints(self) -> list[int]:
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).

def enabled_breakpoints(self) -> list[int]:
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]
def set_breakpoint_lines(self, lines: list[int], disabled: list[int] | None = None):
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.

def toggle_breakpoint(self, line: int):
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).

def toggle_breakpoint_enabled(self, line: int):
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.

def set_breakpoints_enabled(self, lines: list[int], enabled: bool):
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).

def set_breakpoint_problems(self, problems: dict[int, str]):
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.

def breakpoint_tip(self, line: int) -> str:
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 ""
def set_execution_line(self, line: int | None):
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.

def set_coverage( self, lines: dict[int, int] | None, branches: dict[int, tuple[int, int]] | None = None):
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)).

def coverage_state(self, line: int) -> str | None:
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).

def missed_starts(self) -> list[int]:
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).

def go_to_missed(self, forward: bool = True) -> bool:
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.

def refresh_gutter(self):
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).

def coverage_note(self, line: int) -> str:
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.

def set_gremlins(self, found: list[vibefpmgui.gremlins.Gremlin]):
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.

def gremlin_notes(self, line: int) -> list[str]:
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).

def update_gutter_width(self, *_):
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)
def paint_gutter(self, event):
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()
def set_change_marks(self, marks: dict[int, str], deletions: dict[int, tuple[str, int]]):
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()
def line_at_y(self, y: int) -> int:
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
def set_markers(self, markers: dict[int, list[tuple[str, str]]]):
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()