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
« 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.
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"""
12from collections.abc import Callable
13from pathlib import Path
14from typing import NamedTuple
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)
29from . import highlighter as hl
30from .source_text import search_text
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
36PARTS_ROLE = Qt.UserRole + 1 # how a row is drawn (see _ResultDelegate)
37RESULT_ROLE = Qt.UserRole + 2 # (path, Match or None)
39MATCH_COLOR = QColor(242, 201, 76, 110)
40REMOVED_COLOR = QColor(255, 107, 107, 90)
41ADDED_COLOR = QColor(64, 192, 87, 100)
44class Root(NamedTuple):
45 """A package folder (the project's, or a dependency's)."""
46 name: str
47 directory: Path
48 dependency: bool
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 "…")
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
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)
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
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
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
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
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
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)."""
186 def __init__(self, tree: "ResultTree"):
187 super().__init__(tree)
188 self.tree = tree
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))
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
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)
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
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()
342class ResultTree(QTreeWidget):
343 """Results grouped by file (see the module's description)."""
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)
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])))
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
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)
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)
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)
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))
432 def clear_results(self):
433 self.clear()
434 self.items.clear()
435 self._file_lines.clear()
436 self._line_formats.clear()
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
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)
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
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]
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()
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