Coverage for vibefpmgui/editor_gutter.py: 89%
333 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-11-01 20:51 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-11-01 20:51 +0000
1"""The editor's gutter: breakpoints, line numbers, change bars, coverage, gremlins and markers.
3A part of `vibefpmgui.code_editor.CodeEditor`."""
5from __future__ import annotations
7import sys
8from typing import TYPE_CHECKING
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
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)
28if TYPE_CHECKING:
29 from .code_editor import CodeEditor
30from . import hover_card
33class LineNumberArea(QWidget):
34 """The gutter: line numbers plus warning/error markers."""
36 def __init__(self, editor: CodeEditor):
37 super().__init__(editor)
38 self.editor = editor
39 self.setMouseTracking(True)
41 def sizeHint(self):
42 return QSize(self.editor.gutter_width(), 0)
44 def paintEvent(self, event):
45 self.editor.paint_gutter(event)
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: 52 ↛ 55line 52 didn't jump to line 55 because the condition on line 52 was always true
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): 58 ↛ 59line 58 didn't jump to line 59 because the condition on line 58 was never true
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 62 ↛ 63line 62 didn't jump to line 63 because the condition on line 62 was never true
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)): 70 ↛ 74line 70 didn't jump to line 74 because the condition on line 70 was always true
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: 75 ↛ 79line 75 didn't jump to line 79 because the condition on line 75 was always true
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)
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(): 89 ↛ 97line 89 didn't jump to line 97 because the condition on line 89 was always true
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: 93 ↛ 94line 93 didn't jump to line 94 because the condition on line 93 was never true
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": 98 ↛ 100line 98 didn't jump to line 100 because the condition on line 98 was always true
99 return "Changed — not saved yet"
100 if change == "saved":
101 return "Changed since the file was opened (saved)"
102 return ""
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)
113 def leaveEvent(self, event):
114 self.editor.hover_popup.schedule_hide() # (unless the mouse went into it)
115 super().leaveEvent(event)
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: 125 ↛ exitline 125 didn't return from function 'mousePressEvent' because the condition on line 125 was always true
126 self.editor.go_to_line(line, select=True)
129class EditorGutterMixin:
130 """The editor's gutter (a part of `vibefpmgui.code_editor.CodeEditor`)."""
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())
137 def _breakpoint_column(self) -> int:
138 return BREAKPOINT_WIDTH if self.debuggable else 0
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})
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)
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]
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(): 162 ↛ 160line 162 didn't jump to line 160 because the condition on line 162 was always true
163 self._breakpoints.append((QTextCursor(block), line not in off))
164 self.breakpoint_problems = {}
165 self.gutter.update()
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()
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: 178 ↛ 179line 178 didn't jump to line 179 because the condition on line 178 was never true
179 return
180 if line not in self.breakpoint_lines(): 180 ↛ 181line 180 didn't jump to line 181 because the condition on line 180 was never true
181 self.toggle_breakpoint(line)
182 return
183 self.set_breakpoints_enabled([line], line in self.disabled_breakpoints())
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: 187 ↛ 188line 187 didn't jump to line 188 because the condition on line 187 was never true
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: 191 ↛ exitline 191 didn't return from function 'set_breakpoints_enabled' because the condition on line 191 was always true
192 self._breakpoints = changed
193 self.gutter.update()
194 self.breakpoints_changed.emit()
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()
202 def breakpoint_tip(self, line: int) -> str:
203 if line == self.execution_line: 203 ↛ 204line 203 didn't jump to line 204 because the condition on line 203 was never true
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 ""
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()
222 def _gremlin_column(self) -> int:
223 return GREMLIN_WIDTH if self.gremlins else 0
225 def _coverage_column(self) -> int:
226 return COVERAGE_WIDTH if self.coverage and self.show_coverage else 0
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()
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"
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
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
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()
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
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()
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, [])]
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)
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()
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()
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)]))
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()
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(): 441 ↛ 447line 441 didn't jump to line 447 because the condition on line 441 was always true
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
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()