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

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: 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) 

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(): 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 "" 

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: 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) 

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(): 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() 

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: 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()) 

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: 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() 

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: 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 "" 

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(): 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 

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()