Coverage for vibefpmgui/result_tree.py: 92%

353 statements  

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

1"""A list of places in files, grouped by file: the *Search*, *References* 

2and *Problems* tabs. 

3 

4Each file has a row with its icon, name, folder, package (for dependencies) 

5and number of results; under it, a row per result with the line number and 

6the line, in the colors the editor would give it, with the result's part 

7highlighted (or, while replacing, struck through, with the replacement 

8after it); for a problem, its line number, a colored badge (the rule or 

9kind) and the message. Click a row to open the file there. 

10""" 

11 

12from collections.abc import Callable 

13from pathlib import Path 

14from typing import NamedTuple 

15 

16from pygments import lex 

17from pygments.styles import get_style_by_name 

18from qtpy.QtCore import QRect, QSize, Qt, Signal 

19from qtpy.QtGui import QColor, QFont, QFontMetrics, QPalette 

20from qtpy.QtWidgets import ( 

21 QApplication, 

22 QStyle, 

23 QStyledItemDelegate, 

24 QStyleOptionViewItem, 

25 QTreeWidget, 

26 QTreeWidgetItem, 

27) 

28 

29from . import highlighter as hl 

30from .source_text import search_text 

31 

32PREVIEW_BEFORE = 50 # characters of the line shown before a result 

33PREVIEW_AFTER = 200 

34CONTEXT_LINES = 40 # lexed before a result's line, for its syntax colors 

35 

36PARTS_ROLE = Qt.UserRole + 1 # how a row is drawn (see _ResultDelegate) 

37RESULT_ROLE = Qt.UserRole + 2 # (path, Match or None) 

38 

39MATCH_COLOR = QColor(242, 201, 76, 110) 

40REMOVED_COLOR = QColor(255, 107, 107, 90) 

41ADDED_COLOR = QColor(64, 192, 87, 100) 

42 

43 

44class Root(NamedTuple): 

45 """A package folder (the project's, or a dependency's).""" 

46 name: str 

47 directory: Path 

48 dependency: bool 

49 

50 

51class Match(NamedTuple): 

52 """A place in a file, with the text shown for it.""" 

53 line: int # 1-based 

54 column: int # 0-based 

55 end_line: int 

56 end_column: int 

57 before: str # the line's text around the match, for the results list 

58 text: str # (the matched part of the first line) 

59 after: str 

60 start: int = 0 # the column ``before`` starts at (after any "…") 

61 

62 

63class Problem(NamedTuple): 

64 """An issue in a file (a compiler message, fortitude, fortls, spelling, 

65 AI review), for the Problems tab.""" 

66 line: int # 1-based 

67 column: int # 0-based 

68 end_line: int 

69 end_column: int 

70 severity: str # e.g. "error", "lint", "spelling" (see editor.MARKER_COLORS) 

71 badge: str # shown in the colored badge: the rule code or the kind 

72 message: str 

73 color: str # the badge's color 

74 source: str # which tool: "build", "fortitude", "fortls", "spelling", "review" 

75 issue: object = None # fortitude's issue (a lint.LintIssue), with its fix if it has one 

76 

77 

78def make_match(line_text: str, line: int, column: int, end_line: int, end_column: int) -> Match: 

79 """A Match for a place on a line (the preview is cut from the line).""" 

80 end = end_column if end_line == line else len(line_text) 

81 before = line_text[:column].lstrip() 

82 first = column - len(before) 

83 if len(before) > PREVIEW_BEFORE: 

84 before, first = "…" + before[-PREVIEW_BEFORE:], column - PREVIEW_BEFORE 

85 return Match(line, column, end_line, end_column, before, line_text[column:end][:PREVIEW_AFTER], 

86 line_text[end:][:PREVIEW_AFTER], first) 

87 

88 

89def root_for(path: Path, roots: list[Root]) -> Root | None: 

90 """The package a file is in (the innermost, for a package inside another).""" 

91 best, best_len = None, -1 

92 for root in roots: 

93 top = root.directory.resolve() 

94 if top in path.parents and len(str(top)) > best_len: 

95 best, best_len = root, len(str(top)) 

96 return best 

97 

98 

99def file_labels(path: Path, root: Root | None, project_dir: Path | None) -> tuple[str, str]: 

100 """(folder, package) shown after a file's name: the project's files by 

101 their folder in the project, a dependency's by its folder in the 

102 dependency, with "· name" for the package.""" 

103 base = root.directory.resolve() if root is not None else None 

104 if root is not None and not root.dependency and project_dir is not None: 

105 base = project_dir.resolve() 

106 folder = str(path.parent) 

107 if base is not None: 107 ↛ 112line 107 didn't jump to line 112 because the condition on line 107 was always true

108 try: 

109 folder = path.parent.relative_to(base).as_posix() 

110 except ValueError: 

111 pass 

112 folder = "" if folder == "." else folder 

113 package = f"· {root.name}" if root is not None and root.dependency else "" 

114 return folder, package 

115 

116 

117def syntax_formats(lines: list[str], line: int, path: Path, style, formats: dict) -> list | None: 

118 """The syntax format of each column of ``lines``' ``line`` (1-based; None: 

119 plain), in a Pygments ``style``, lexed with the lines before it for 

120 context (so a line inside a multi-line string or comment is colored as 

121 one). ``formats``: token type -> format, kept between calls.""" 

122 if not 0 < line <= len(lines): 122 ↛ 123line 122 didn't jump to line 123 because the condition on line 122 was never true

123 return None 

124 limit = 4000 # (long lines: only their start is shown anyway) 

125 context = [s[:limit] for s in lines[max(0, line - 1 - CONTEXT_LINES):line]] 

126 chunk = "\n".join(context) 

127 offset = len(chunk) - len(context[-1]) 

128 lexer = hl.lexer_for_path(path) # (it keeps the positions as they are) 

129 result = [None] * len(context[-1]) 

130 pos = 0 

131 try: 

132 for ttype, value in lex(chunk, lexer): 

133 end = pos + len(value) 

134 if end > offset: 

135 fmt = formats.get(ttype) 

136 if fmt is None: 

137 fmt = formats[ttype] = hl.token_format(style, ttype) 

138 for i in range(max(pos, offset), min(end, len(chunk))): 

139 result[i - offset] = fmt 

140 pos = end 

141 except Exception: # noqa: BLE001 - never let highlighting break a list 

142 return None 

143 return result 

144 

145 

146def paint_code(painter, rect: QRect, code: str, formats: list | None, column: int, font: QFont, 

147 plain: QColor) -> int: 

148 """Draw a line of code from ``rect``'s left, a run of one syntax color at 

149 a time (``formats``: each column's format, None: plain; ``code`` starts at 

150 the line's ``column``), clipped to ``rect``; the x it ended at.""" 

151 x, start = rect.left(), 0 

152 for i in range(1, len(code) + 1): 

153 fmt = formats[column + start] if formats and column + start < len(formats) else None 

154 if i < len(code) and formats and column + i < len(formats) and formats[column + i] is fmt: 

155 continue 

156 run = QFont(font) 

157 if fmt is not None: 

158 run.setBold(fmt.fontWeight() >= QFont.Bold) 

159 run.setItalic(fmt.fontItalic()) 

160 brush = fmt.foreground() if fmt is not None else None 

161 painter.setFont(run) 

162 painter.setPen(brush.color() if brush is not None and brush.style() != Qt.NoBrush else plain) 

163 piece = code[start:i] 

164 width = QFontMetrics(run).horizontalAdvance(piece) 

165 painter.drawText(QRect(x, rect.top(), width, rect.height()), Qt.AlignVCenter | Qt.AlignLeft, piece) 

166 x += width 

167 if x > rect.right(): 

168 break 

169 start = i 

170 return x 

171 

172 

173def _badge_font(font: QFont) -> QFont: 

174 """The small bold font of a problem's badge.""" 

175 small = QFont(font) 

176 small.setPointSizeF(max(6.0, small.pointSizeF() - 2)) 

177 small.setBold(True) 

178 return small 

179 

180 

181class _ResultDelegate(QStyledItemDelegate): 

182 """Draws a file row (icon, name, folder, package, count) or a result 

183 row (line number and the line, with the result highlighted, or the 

184 replacement shown when replacing).""" 

185 

186 def __init__(self, tree: "ResultTree"): 

187 super().__init__(tree) 

188 self.tree = tree 

189 

190 def sizeHint(self, option, index): 

191 size = super().sizeHint(option, index) 

192 line_height = QFontMetrics(self.tree.mono).height() + 6 

193 parts = index.data(PARTS_ROLE) 

194 if parts is not None and parts[0] == "problem": # (the message wraps: as tall as it needs) 

195 # Where the row starts: indented one step per level (plus one for the root's arrows). 

196 depth, parent = 0, index.parent() 

197 while parent.isValid(): 

198 depth, parent = depth + 1, parent.parent() 

199 left = self.tree.indentation() * (depth + int(self.tree.rootIsDecorated())) 

200 width = max(80, self.tree.viewport().width() - left - 2) 

201 _badge, message = self._problem_layout(option.font, parts[2], QRect(0, 0, width, line_height)) 

202 height = QFontMetrics(option.font).boundingRect( 

203 message, Qt.TextWordWrap | Qt.AlignLeft, parts[2].message).height() 

204 return QSize(0, max(line_height, height + 6)) # (no width of its own: it wraps to the view's) 

205 return QSize(size.width(), max(size.height(), line_height)) 

206 

207 def _problem_layout(self, font: QFont, problem, rect: QRect) -> tuple[QRect, QRect]: 

208 """Where a problem row's badge goes (beside its line number, on the 

209 first line) and the rectangle its message wraps in.""" 

210 number = QFontMetrics(self.tree.mono).horizontalAdvance(f"{problem.line:>5} ") 

211 width = QFontMetrics(_badge_font(font)).horizontalAdvance(problem.badge) + 10 

212 first = QFontMetrics(self.tree.mono).height() + 6 # (the first line's height) 

213 badge = QRect(rect.left() + 4 + number, rect.top() + 3, width, first - 6) 

214 message = QRect(badge.right() + 9, rect.top() + 3, max(20, rect.right() - badge.right() - 13), 

215 max(first - 6, rect.height() - 6)) 

216 return badge, message 

217 

218 def paint(self, painter, option, index): 

219 parts = index.data(PARTS_ROLE) 

220 if parts is None: 220 ↛ 221line 220 didn't jump to line 221 because the condition on line 220 was never true

221 super().paint(painter, option, index) 

222 return 

223 opt = QStyleOptionViewItem(option) 

224 self.initStyleOption(opt, index) 

225 opt.text = "" 

226 style = opt.widget.style() if opt.widget else QApplication.style() 

227 style.drawControl(QStyle.CE_ItemViewItem, opt, painter, opt.widget) 

228 selected = bool(opt.state & QStyle.State_Selected) 

229 group = QPalette.Active if opt.state & QStyle.State_Active else QPalette.Inactive 

230 color = opt.palette.color(group, QPalette.HighlightedText if selected else QPalette.Text) 

231 dim = QColor(color) 

232 dim.setAlpha(140) 

233 rect = opt.rect 

234 x = rect.left() + 4 

235 painter.save() 

236 painter.setClipRect(rect) 

237 

238 def draw(text, font, pen, background=None, strike=False, formats=None, column=0): 

239 """Draw text from x on; ``formats``: the syntax colors of the 

240 line's columns (``text`` starts at ``column``).""" 

241 nonlocal x 

242 if not text: 

243 return 

244 runs = [(text, None)] 

245 if formats: # split where the syntax color changes 

246 runs, start = [], 0 

247 fmt_at = [formats[column + i] if column + i < len(formats) else None 

248 for i in range(len(text))] 

249 for i in range(1, len(text) + 1): 

250 if i == len(text) or fmt_at[i] is not fmt_at[start]: 

251 runs.append((text[start:i], fmt_at[start])) 

252 start = i 

253 pieces = [] 

254 for piece, fmt in runs: 

255 f = QFont(font) 

256 f.setStrikeOut(strike) 

257 if fmt is not None: 

258 f.setBold(fmt.fontWeight() >= QFont.Bold) 

259 f.setItalic(fmt.fontItalic()) 

260 pieces.append((piece, f, fmt, QFontMetrics(f).horizontalAdvance(piece))) 

261 if background is not None: 

262 width = sum(p[3] for p in pieces) 

263 painter.fillRect(QRect(x, rect.top() + 2, width, rect.height() - 4), background) 

264 for piece, f, fmt, width in pieces: 

265 painter.setFont(f) 

266 brush = fmt.foreground() if fmt is not None else None 

267 painter.setPen(brush.color() if brush is not None and brush.style() != Qt.NoBrush 

268 else pen) 

269 painter.drawText(QRect(x, rect.top(), width, rect.height()), Qt.AlignVCenter | Qt.AlignLeft, 

270 piece) 

271 x += width 

272 

273 if parts[0] == "file": 

274 _kind, path, name, folder, package, count = parts 

275 icon = self.tree.icon_for(path) if self.tree.icon_for else None 

276 if icon is not None and not icon.isNull(): 276 ↛ 280line 276 didn't jump to line 280 because the condition on line 276 was always true

277 size = QFontMetrics(opt.font).height() 

278 icon.paint(painter, QRect(x, rect.top() + (rect.height() - size) // 2, size, size)) 

279 x += size + 5 

280 bold = QFont(opt.font) 

281 bold.setBold(True) 

282 draw(name, bold, color) 

283 draw(f" {folder}" if folder else "", opt.font, dim) 

284 draw(f" {package}" if package else "", opt.font, dim) 

285 # The count at the right, in a small pill. 

286 text = str(count) 

287 w = QFontMetrics(opt.font).horizontalAdvance(text) + 12 

288 pill = QRect(rect.right() - w - 6, rect.top() + 3, w, rect.height() - 6) 

289 fill = QColor(color) 

290 fill.setAlpha(40) 

291 painter.setPen(Qt.NoPen) 

292 painter.setBrush(fill) 

293 painter.drawRoundedRect(pill, pill.height() / 2, pill.height() / 2) 

294 painter.setPen(color) 

295 painter.setFont(opt.font) 

296 painter.drawText(pill, Qt.AlignCenter, text) 

297 elif parts[0] == "problem": 

298 _kind, path, problem = parts 

299 badge, message = self._problem_layout(opt.font, problem, rect) 

300 # The line number and badge on the first line; the message wraps beside them. 

301 painter.setFont(self.tree.mono) 

302 painter.setPen(dim) 

303 painter.drawText(QRect(x, badge.top(), badge.left() - x, badge.height()), 

304 Qt.AlignVCenter | Qt.AlignLeft, f"{problem.line:>5} ") 

305 painter.setPen(Qt.NoPen) 

306 painter.setBrush(QColor(problem.color)) 

307 painter.drawRoundedRect(badge, 3, 3) 

308 painter.setPen(QColor("white")) 

309 painter.setFont(_badge_font(opt.font)) 

310 painter.drawText(badge, Qt.AlignCenter, problem.badge) 

311 painter.setPen(color) 

312 painter.setFont(opt.font) 

313 first = QFontMetrics(opt.font).height() 

314 # (one line: centred on the badge, as before; more: from the top) 

315 if QFontMetrics(opt.font).boundingRect(message, Qt.TextWordWrap, problem.message).height() <= first: 

316 message.setTop(badge.top()) 

317 message.setHeight(badge.height()) 

318 painter.drawText(message, Qt.AlignVCenter | Qt.AlignLeft, problem.message) 

319 else: 

320 painter.drawText(message, Qt.TextWordWrap | Qt.AlignTop | Qt.AlignLeft, problem.message) 

321 else: 

322 _kind, path, found = parts 

323 mono = self.tree.mono 

324 # Syntax colors (none on a selected row: its text color reads best there). 

325 formats = None if selected else self.tree.line_formats(path, found.line) 

326 draw(f"{found.line:>5} ", mono, dim) 

327 before = found.before 

328 if before.startswith("…") and found.column - found.start < len(before): 328 ↛ 329line 328 didn't jump to line 329 because the condition on line 328 was never true

329 draw("…", mono, dim) 

330 before = before[1:] 

331 draw(before, mono, color, formats=formats, column=found.start) 

332 if self.tree.replacement is not None: 332 ↛ 333line 332 didn't jump to line 333 because the condition on line 332 was never true

333 draw(found.text, mono, color, REMOVED_COLOR, strike=True, formats=formats, 

334 column=found.column) 

335 draw(self.tree.replacement(found.text), mono, color, ADDED_COLOR) 

336 else: 

337 draw(found.text, mono, color, MATCH_COLOR, formats=formats, column=found.column) 

338 draw(found.after, mono, color, formats=formats, column=found.column + len(found.text)) 

339 painter.restore() 

340 

341 

342class ResultTree(QTreeWidget): 

343 """Results grouped by file (see the module's description).""" 

344 

345 open_requested = Signal(str, int, int, int, int) # file, line, column, end line, end column 

346 keep_requested = Signal(str) # a result double-clicked: its file, to keep open (not a preview) 

347 

348 def __init__(self, parent=None): 

349 super().__init__(parent) 

350 self.mono = QFont() # for the lines (set by the window) 

351 self.icon_for: Callable[[Path], object] | None = None # a file's QIcon (set by the window) 

352 self.text_for: Callable[[Path], str | None] | None = None # an open file's text (set by the window) 

353 # While replacing: the replacement shown for a result's text. 

354 self.replacement: Callable[[str], str] | None = None 

355 self.items: dict[Path, QTreeWidgetItem] = {} 

356 self._style = get_style_by_name(hl.LIGHT_STYLE) # (see refresh_style) 

357 self._formats: dict = {} # token type -> format 

358 self._file_lines: dict[Path, list[str]] = {} 

359 self._line_formats: dict[tuple[Path, int], list | None] = {} 

360 self.setHeaderHidden(True) 

361 self.setUniformRowHeights(True) 

362 self.setItemDelegate(_ResultDelegate(self)) 

363 self.itemClicked.connect(self.open_item) 

364 self.itemActivated.connect(self.open_item) 

365 self.itemDoubleClicked.connect( 

366 lambda item, _c: (data := item.data(0, RESULT_ROLE)) and self.keep_requested.emit(str(data[0]))) 

367 

368 # ------------------------------------------------------------ rows 

369 def set_file(self, path: Path, matches: list[Match], folder: str = "", package: str = "", 

370 expanded: bool | None = None) -> QTreeWidgetItem: 

371 """Add a file's row (at the end) with its results, or fill it again.""" 

372 item = self.items.get(path) 

373 new = item is None 

374 if new: 

375 item = self.items[path] = QTreeWidgetItem() 

376 self.addTopLevelItem(item) 

377 was_expanded = item.isExpanded() 

378 item.takeChildren() 

379 item.setText(0, f"{folder}/{path.name}" if folder else path.name) 

380 item.setToolTip(0, str(path)) 

381 item.setData(0, PARTS_ROLE, ("file", path, path.name, folder, package, len(matches))) 

382 item.setData(0, RESULT_ROLE, (str(path), None)) 

383 for found in matches: 

384 child = QTreeWidgetItem([f"{found.line}: {found.before}{found.text}{found.after}"]) 

385 child.setToolTip(0, f"{path}:{found.line}") 

386 child.setData(0, PARTS_ROLE, ("match", path, found)) 

387 child.setData(0, RESULT_ROLE, (str(path), found)) 

388 item.addChild(child) 

389 item.setExpanded(expanded if expanded is not None else (True if new else was_expanded)) 

390 return item 

391 

392 def set_problems(self, path: Path, problems: list[Problem], folder: str = "", package: str = ""): 

393 """Show a file's problems (instead of whatever was shown), keeping 

394 the scroll position when it's the same file again.""" 

395 scroll = self.verticalScrollBar().value() if path in self.items else 0 

396 self.clear() 

397 self.items.clear() 

398 self.forget(path) 

399 item = self.items[path] = QTreeWidgetItem() 

400 self.addTopLevelItem(item) 

401 item.setText(0, path.name) 

402 item.setToolTip(0, str(path)) 

403 item.setData(0, PARTS_ROLE, ("file", path, path.name, folder, package, len(problems))) 

404 item.setData(0, RESULT_ROLE, (str(path), None)) 

405 for problem in problems: 

406 child = QTreeWidgetItem([f"{problem.line}: {problem.badge} {problem.message}"]) 

407 lines = self.file_lines(path) 

408 code = lines[problem.line - 1].strip() if 0 < problem.line <= len(lines) else "" 

409 child.setToolTip(0, f"{problem.message}\n\nLine {problem.line}: {code[:200]}") 

410 child.setData(0, PARTS_ROLE, ("problem", path, problem)) 

411 child.setData(0, RESULT_ROLE, (str(path), problem)) 

412 item.addChild(child) 

413 item.setExpanded(True) 

414 self.verticalScrollBar().setValue(scroll) 

415 

416 def set_wrapping(self, on: bool): 

417 """Rows as tall as their (wrapped) text: the Problems tab's messages 

418 can be long (an AI review's). Off: one line each, quicker for long lists.""" 

419 self.setUniformRowHeights(not on) 

420 self.setWordWrap(on) 

421 

422 def resizeEvent(self, event): 

423 super().resizeEvent(event) 

424 if not self.uniformRowHeights() and event.size().width() != event.oldSize().width(): 

425 self.doItemsLayout() # (wrapped rows: new heights for the new width) 

426 

427 def remove_file(self, path: Path): 

428 item = self.items.pop(path, None) 

429 if item is not None: 429 ↛ exitline 429 didn't return from function 'remove_file' because the condition on line 429 was always true

430 self.takeTopLevelItem(self.indexOfTopLevelItem(item)) 

431 

432 def clear_results(self): 

433 self.clear() 

434 self.items.clear() 

435 self._file_lines.clear() 

436 self._line_formats.clear() 

437 

438 def toggle_collapse(self) -> bool: 

439 """Collapse every file, or expand them all if none is expanded. 

440 Returns whether they are now expanded.""" 

441 items = [self.topLevelItem(i) for i in range(self.topLevelItemCount())] 

442 expand = not any(item.isExpanded() for item in items) 

443 for item in items: 

444 item.setExpanded(expand) 

445 return expand 

446 

447 def open_item(self, item: QTreeWidgetItem, _column: int = 0): 

448 """Open a result (a file's row: its first result).""" 

449 data = item.data(0, RESULT_ROLE) 

450 if not data: 450 ↛ 451line 450 didn't jump to line 451 because the condition on line 450 was never true

451 return 

452 path, found = data 

453 if found is None: 453 ↛ 454line 453 didn't jump to line 454 because the condition on line 453 was never true

454 if not item.childCount(): 

455 return 

456 found = item.child(0).data(0, RESULT_ROLE)[1] 

457 self.open_requested.emit(path, found.line, found.column, found.end_line, found.end_column) 

458 

459 # ---------------------------------------------------------- text 

460 def file_lines(self, path: Path) -> list[str]: 

461 """A file's lines, as in the editor if it's open (kept for drawing).""" 

462 lines = self._file_lines.get(path) 

463 if lines is None: 

464 text = self.text_for(path) if self.text_for is not None else None 

465 if text is None: 

466 text = search_text(path) or "" 

467 lines = text.split("\n") 

468 if len(self._file_lines) > 100: # (keep the most recent files) 468 ↛ 469line 468 didn't jump to line 469 because the condition on line 468 was never true

469 self._file_lines.pop(next(iter(self._file_lines))) 

470 self._file_lines[path] = lines 

471 return lines 

472 

473 def forget(self, path: Path): 

474 """A file changed: read and lex it again when its lines are drawn.""" 

475 self._file_lines.pop(path, None) 

476 for key in [k for k in self._line_formats if k[0] == path]: 476 ↛ 477line 476 didn't jump to line 477 because the loop on line 476 never started

477 del self._line_formats[key] 

478 

479 # -------------------------------------------------- syntax colors 

480 def refresh_style(self): 

481 """Use the editor's syntax colors (View → Syntax Colors), on its 

482 background.""" 

483 self._style = get_style_by_name(hl.style_name(QApplication.palette())) 

484 self._formats.clear() 

485 self._line_formats.clear() 

486 palette = self.palette() 

487 palette.setColor(QPalette.Base, QColor(self._style.background_color)) 

488 palette.setColor(QPalette.Text, hl.style_text_color(self._style)) 

489 self.setPalette(palette) 

490 self.viewport().update() 

491 

492 def line_formats(self, path: Path, line: int) -> list | None: 

493 """The syntax format of each column of a line (None: plain), lexed 

494 with the lines before it for context (so a line inside a 

495 multi-line string or comment is colored as one).""" 

496 key = (path, line) 

497 if key in self._line_formats: 

498 return self._line_formats[key] 

499 result = syntax_formats(self.file_lines(path), line, path, self._style, self._formats) 

500 self._line_formats[key] = result 

501 return result