vibefpmgui.result_tree

A list of places in files, grouped by file: the Search, References and Problems tabs.

Each file has a row with its icon, name, folder, package (for dependencies) and number of results; under it, a row per result with the line number and the line, in the colors the editor would give it, with the result's part highlighted (or, while replacing, struck through, with the replacement after it); for a problem, its line number, a colored badge (the rule or kind) and the message. Click a row to open the file there.

  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:
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):
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:
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():
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):
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:
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:
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:
451            return
452        path, found = data
453        if found is None:
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)
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]:
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
PREVIEW_BEFORE = 50
PREVIEW_AFTER = 200
CONTEXT_LINES = 40
PARTS_ROLE = 257
RESULT_ROLE = 258
MATCH_COLOR = PySide6.QtGui.QColor.fromRgbF(0.949020, 0.788235, 0.298039, 0.431373)
REMOVED_COLOR = PySide6.QtGui.QColor.fromRgbF(1.000000, 0.419608, 0.419608, 0.352941)
ADDED_COLOR = PySide6.QtGui.QColor.fromRgbF(0.250980, 0.752941, 0.341176, 0.392157)
class Root(typing.NamedTuple):
45class Root(NamedTuple):
46    """A package folder (the project's, or a dependency's)."""
47    name: str
48    directory: Path
49    dependency: bool

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

Root(name: str, directory: pathlib.Path, dependency: bool)

Create new instance of Root(name, directory, dependency)

name: str

Alias for field number 0

directory: pathlib.Path

Alias for field number 1

dependency: bool

Alias for field number 2

class Match(typing.NamedTuple):
52class Match(NamedTuple):
53    """A place in a file, with the text shown for it."""
54    line: int        # 1-based
55    column: int      # 0-based
56    end_line: int
57    end_column: int
58    before: str      # the line's text around the match, for the results list
59    text: str        # (the matched part of the first line)
60    after: str
61    start: int = 0   # the column ``before`` starts at (after any "…")

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

Match( line: int, column: int, end_line: int, end_column: int, before: str, text: str, after: str, start: int = 0)

Create new instance of Match(line, column, end_line, end_column, before, text, after, start)

line: int

Alias for field number 0

column: int

Alias for field number 1

end_line: int

Alias for field number 2

end_column: int

Alias for field number 3

before: str

Alias for field number 4

text: str

Alias for field number 5

after: str

Alias for field number 6

start: int

Alias for field number 7

class Problem(typing.NamedTuple):
64class Problem(NamedTuple):
65    """An issue in a file (a compiler message, fortitude, fortls, spelling,
66    AI review), for the Problems tab."""
67    line: int        # 1-based
68    column: int      # 0-based
69    end_line: int
70    end_column: int
71    severity: str    # e.g. "error", "lint", "spelling" (see editor.MARKER_COLORS)
72    badge: str       # shown in the colored badge: the rule code or the kind
73    message: str
74    color: str       # the badge's color
75    source: str      # which tool: "build", "fortitude", "fortls", "spelling", "review"
76    issue: object = None  # fortitude's issue (a lint.LintIssue), with its fix if it has one

An issue in a file (a compiler message, fortitude, fortls, spelling, AI review), for the Problems tab.

Problem( line: int, column: int, end_line: int, end_column: int, severity: str, badge: str, message: str, color: str, source: str, issue: object = None)

Create new instance of Problem(line, column, end_line, end_column, severity, badge, message, color, source, issue)

line: int

Alias for field number 0

column: int

Alias for field number 1

end_line: int

Alias for field number 2

end_column: int

Alias for field number 3

severity: str

Alias for field number 4

badge: str

Alias for field number 5

message: str

Alias for field number 6

color: str

Alias for field number 7

source: str

Alias for field number 8

issue: object

Alias for field number 9

def make_match( line_text: str, line: int, column: int, end_line: int, end_column: int) -> Match:
79def make_match(line_text: str, line: int, column: int, end_line: int, end_column: int) -> Match:
80    """A Match for a place on a line (the preview is cut from the line)."""
81    end = end_column if end_line == line else len(line_text)
82    before = line_text[:column].lstrip()
83    first = column - len(before)
84    if len(before) > PREVIEW_BEFORE:
85        before, first = "…" + before[-PREVIEW_BEFORE:], column - PREVIEW_BEFORE
86    return Match(line, column, end_line, end_column, before, line_text[column:end][:PREVIEW_AFTER],
87                 line_text[end:][:PREVIEW_AFTER], first)

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

def root_for( path: pathlib.Path, roots: list[Root]) -> Root | None:
90def root_for(path: Path, roots: list[Root]) -> Root | None:
91    """The package a file is in (the innermost, for a package inside another)."""
92    best, best_len = None, -1
93    for root in roots:
94        top = root.directory.resolve()
95        if top in path.parents and len(str(top)) > best_len:
96            best, best_len = root, len(str(top))
97    return best

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

def file_labels( path: pathlib.Path, root: Root | None, project_dir: pathlib.Path | None) -> tuple[str, str]:
100def file_labels(path: Path, root: Root | None, project_dir: Path | None) -> tuple[str, str]:
101    """(folder, package) shown after a file's name: the project's files by
102    their folder in the project, a dependency's by its folder in the
103    dependency, with "· name" for the package."""
104    base = root.directory.resolve() if root is not None else None
105    if root is not None and not root.dependency and project_dir is not None:
106        base = project_dir.resolve()
107    folder = str(path.parent)
108    if base is not None:
109        try:
110            folder = path.parent.relative_to(base).as_posix()
111        except ValueError:
112            pass
113    folder = "" if folder == "." else folder
114    package = f"· {root.name}" if root is not None and root.dependency else ""
115    return folder, package

(folder, package) shown after a file's name: the project's files by their folder in the project, a dependency's by its folder in the dependency, with "· name" for the package.

def syntax_formats( lines: list[str], line: int, path: pathlib.Path, style, formats: dict) -> list | None:
118def syntax_formats(lines: list[str], line: int, path: Path, style, formats: dict) -> list | None:
119    """The syntax format of each column of ``lines``' ``line`` (1-based; None:
120    plain), in a Pygments ``style``, lexed with the lines before it for
121    context (so a line inside a multi-line string or comment is colored as
122    one). ``formats``: token type -> format, kept between calls."""
123    if not 0 < line <= len(lines):
124        return None
125    limit = 4000  # (long lines: only their start is shown anyway)
126    context = [s[:limit] for s in lines[max(0, line - 1 - CONTEXT_LINES):line]]
127    chunk = "\n".join(context)
128    offset = len(chunk) - len(context[-1])
129    lexer = hl.lexer_for_path(path)  # (it keeps the positions as they are)
130    result = [None] * len(context[-1])
131    pos = 0
132    try:
133        for ttype, value in lex(chunk, lexer):
134            end = pos + len(value)
135            if end > offset:
136                fmt = formats.get(ttype)
137                if fmt is None:
138                    fmt = formats[ttype] = hl.token_format(style, ttype)
139                for i in range(max(pos, offset), min(end, len(chunk))):
140                    result[i - offset] = fmt
141            pos = end
142    except Exception:  # noqa: BLE001 - never let highlighting break a list
143        return None
144    return result

The syntax format of each column of lines' line (1-based; None: plain), in a Pygments style, lexed with the lines before it for context (so a line inside a multi-line string or comment is colored as one). formats: token type -> format, kept between calls.

def paint_code( painter, rect: PySide6.QtCore.QRect, code: str, formats: list | None, column: int, font: PySide6.QtGui.QFont, plain: PySide6.QtGui.QColor) -> int:
147def paint_code(painter, rect: QRect, code: str, formats: list | None, column: int, font: QFont,
148               plain: QColor) -> int:
149    """Draw a line of code from ``rect``'s left, a run of one syntax color at
150    a time (``formats``: each column's format, None: plain; ``code`` starts at
151    the line's ``column``), clipped to ``rect``; the x it ended at."""
152    x, start = rect.left(), 0
153    for i in range(1, len(code) + 1):
154        fmt = formats[column + start] if formats and column + start < len(formats) else None
155        if i < len(code) and formats and column + i < len(formats) and formats[column + i] is fmt:
156            continue
157        run = QFont(font)
158        if fmt is not None:
159            run.setBold(fmt.fontWeight() >= QFont.Bold)
160            run.setItalic(fmt.fontItalic())
161        brush = fmt.foreground() if fmt is not None else None
162        painter.setFont(run)
163        painter.setPen(brush.color() if brush is not None and brush.style() != Qt.NoBrush else plain)
164        piece = code[start:i]
165        width = QFontMetrics(run).horizontalAdvance(piece)
166        painter.drawText(QRect(x, rect.top(), width, rect.height()), Qt.AlignVCenter | Qt.AlignLeft, piece)
167        x += width
168        if x > rect.right():
169            break
170        start = i
171    return x

Draw a line of code from rect's left, a run of one syntax color at a time (formats: each column's format, None: plain; code starts at the line's column), clipped to rect; the x it ended at.

class ResultTree(PySide6.QtWidgets.QTreeWidget):
343class ResultTree(QTreeWidget):
344    """Results grouped by file (see the module's description)."""
345
346    open_requested = Signal(str, int, int, int, int)  # file, line, column, end line, end column
347    keep_requested = Signal(str)  # a result double-clicked: its file, to keep open (not a preview)
348
349    def __init__(self, parent=None):
350        super().__init__(parent)
351        self.mono = QFont()  # for the lines (set by the window)
352        self.icon_for: Callable[[Path], object] | None = None    # a file's QIcon (set by the window)
353        self.text_for: Callable[[Path], str | None] | None = None  # an open file's text (set by the window)
354        # While replacing: the replacement shown for a result's text.
355        self.replacement: Callable[[str], str] | None = None
356        self.items: dict[Path, QTreeWidgetItem] = {}
357        self._style = get_style_by_name(hl.LIGHT_STYLE)  # (see refresh_style)
358        self._formats: dict = {}                        # token type -> format
359        self._file_lines: dict[Path, list[str]] = {}
360        self._line_formats: dict[tuple[Path, int], list | None] = {}
361        self.setHeaderHidden(True)
362        self.setUniformRowHeights(True)
363        self.setItemDelegate(_ResultDelegate(self))
364        self.itemClicked.connect(self.open_item)
365        self.itemActivated.connect(self.open_item)
366        self.itemDoubleClicked.connect(
367            lambda item, _c: (data := item.data(0, RESULT_ROLE)) and self.keep_requested.emit(str(data[0])))
368
369    # ------------------------------------------------------------ rows
370    def set_file(self, path: Path, matches: list[Match], folder: str = "", package: str = "",
371                 expanded: bool | None = None) -> QTreeWidgetItem:
372        """Add a file's row (at the end) with its results, or fill it again."""
373        item = self.items.get(path)
374        new = item is None
375        if new:
376            item = self.items[path] = QTreeWidgetItem()
377            self.addTopLevelItem(item)
378        was_expanded = item.isExpanded()
379        item.takeChildren()
380        item.setText(0, f"{folder}/{path.name}" if folder else path.name)
381        item.setToolTip(0, str(path))
382        item.setData(0, PARTS_ROLE, ("file", path, path.name, folder, package, len(matches)))
383        item.setData(0, RESULT_ROLE, (str(path), None))
384        for found in matches:
385            child = QTreeWidgetItem([f"{found.line}: {found.before}{found.text}{found.after}"])
386            child.setToolTip(0, f"{path}:{found.line}")
387            child.setData(0, PARTS_ROLE, ("match", path, found))
388            child.setData(0, RESULT_ROLE, (str(path), found))
389            item.addChild(child)
390        item.setExpanded(expanded if expanded is not None else (True if new else was_expanded))
391        return item
392
393    def set_problems(self, path: Path, problems: list[Problem], folder: str = "", package: str = ""):
394        """Show a file's problems (instead of whatever was shown), keeping
395        the scroll position when it's the same file again."""
396        scroll = self.verticalScrollBar().value() if path in self.items else 0
397        self.clear()
398        self.items.clear()
399        self.forget(path)
400        item = self.items[path] = QTreeWidgetItem()
401        self.addTopLevelItem(item)
402        item.setText(0, path.name)
403        item.setToolTip(0, str(path))
404        item.setData(0, PARTS_ROLE, ("file", path, path.name, folder, package, len(problems)))
405        item.setData(0, RESULT_ROLE, (str(path), None))
406        for problem in problems:
407            child = QTreeWidgetItem([f"{problem.line}: {problem.badge} {problem.message}"])
408            lines = self.file_lines(path)
409            code = lines[problem.line - 1].strip() if 0 < problem.line <= len(lines) else ""
410            child.setToolTip(0, f"{problem.message}\n\nLine {problem.line}: {code[:200]}")
411            child.setData(0, PARTS_ROLE, ("problem", path, problem))
412            child.setData(0, RESULT_ROLE, (str(path), problem))
413            item.addChild(child)
414        item.setExpanded(True)
415        self.verticalScrollBar().setValue(scroll)
416
417    def set_wrapping(self, on: bool):
418        """Rows as tall as their (wrapped) text: the Problems tab's messages
419        can be long (an AI review's). Off: one line each, quicker for long lists."""
420        self.setUniformRowHeights(not on)
421        self.setWordWrap(on)
422
423    def resizeEvent(self, event):
424        super().resizeEvent(event)
425        if not self.uniformRowHeights() and event.size().width() != event.oldSize().width():
426            self.doItemsLayout()  # (wrapped rows: new heights for the new width)
427
428    def remove_file(self, path: Path):
429        item = self.items.pop(path, None)
430        if item is not None:
431            self.takeTopLevelItem(self.indexOfTopLevelItem(item))
432
433    def clear_results(self):
434        self.clear()
435        self.items.clear()
436        self._file_lines.clear()
437        self._line_formats.clear()
438
439    def toggle_collapse(self) -> bool:
440        """Collapse every file, or expand them all if none is expanded.
441        Returns whether they are now expanded."""
442        items = [self.topLevelItem(i) for i in range(self.topLevelItemCount())]
443        expand = not any(item.isExpanded() for item in items)
444        for item in items:
445            item.setExpanded(expand)
446        return expand
447
448    def open_item(self, item: QTreeWidgetItem, _column: int = 0):
449        """Open a result (a file's row: its first result)."""
450        data = item.data(0, RESULT_ROLE)
451        if not data:
452            return
453        path, found = data
454        if found is None:
455            if not item.childCount():
456                return
457            found = item.child(0).data(0, RESULT_ROLE)[1]
458        self.open_requested.emit(path, found.line, found.column, found.end_line, found.end_column)
459
460    # ---------------------------------------------------------- text
461    def file_lines(self, path: Path) -> list[str]:
462        """A file's lines, as in the editor if it's open (kept for drawing)."""
463        lines = self._file_lines.get(path)
464        if lines is None:
465            text = self.text_for(path) if self.text_for is not None else None
466            if text is None:
467                text = search_text(path) or ""
468            lines = text.split("\n")
469            if len(self._file_lines) > 100:  # (keep the most recent files)
470                self._file_lines.pop(next(iter(self._file_lines)))
471            self._file_lines[path] = lines
472        return lines
473
474    def forget(self, path: Path):
475        """A file changed: read and lex it again when its lines are drawn."""
476        self._file_lines.pop(path, None)
477        for key in [k for k in self._line_formats if k[0] == path]:
478            del self._line_formats[key]
479
480    # -------------------------------------------------- syntax colors
481    def refresh_style(self):
482        """Use the editor's syntax colors (View → Syntax Colors), on its
483        background."""
484        self._style = get_style_by_name(hl.style_name(QApplication.palette()))
485        self._formats.clear()
486        self._line_formats.clear()
487        palette = self.palette()
488        palette.setColor(QPalette.Base, QColor(self._style.background_color))
489        palette.setColor(QPalette.Text, hl.style_text_color(self._style))
490        self.setPalette(palette)
491        self.viewport().update()
492
493    def line_formats(self, path: Path, line: int) -> list | None:
494        """The syntax format of each column of a line (None: plain), lexed
495        with the lines before it for context (so a line inside a
496        multi-line string or comment is colored as one)."""
497        key = (path, line)
498        if key in self._line_formats:
499            return self._line_formats[key]
500        result = syntax_formats(self.file_lines(path), line, path, self._style, self._formats)
501        self._line_formats[key] = result
502        return result

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

ResultTree(parent=None)
349    def __init__(self, parent=None):
350        super().__init__(parent)
351        self.mono = QFont()  # for the lines (set by the window)
352        self.icon_for: Callable[[Path], object] | None = None    # a file's QIcon (set by the window)
353        self.text_for: Callable[[Path], str | None] | None = None  # an open file's text (set by the window)
354        # While replacing: the replacement shown for a result's text.
355        self.replacement: Callable[[str], str] | None = None
356        self.items: dict[Path, QTreeWidgetItem] = {}
357        self._style = get_style_by_name(hl.LIGHT_STYLE)  # (see refresh_style)
358        self._formats: dict = {}                        # token type -> format
359        self._file_lines: dict[Path, list[str]] = {}
360        self._line_formats: dict[tuple[Path, int], list | None] = {}
361        self.setHeaderHidden(True)
362        self.setUniformRowHeights(True)
363        self.setItemDelegate(_ResultDelegate(self))
364        self.itemClicked.connect(self.open_item)
365        self.itemActivated.connect(self.open_item)
366        self.itemDoubleClicked.connect(
367            lambda item, _c: (data := item.data(0, RESULT_ROLE)) and self.keep_requested.emit(str(data[0])))

__init__(self, /, parent: PySide6.QtWidgets.QWidget | None = None, *, columnCount: int | None = None, topLevelItemCount: int | None = None, supportedDragActions: PySide6.QtCore.Qt.DropAction | None = None) -> None

Initialize self. See help(type(self)) for accurate signature.

def open_requested(unknown):

The type of the None singleton.

def keep_requested(unknown):

The type of the None singleton.

mono
icon_for: Callable[[pathlib.Path], object] | None
text_for: Callable[[pathlib.Path], str | None] | None
replacement: Callable[[str], str] | None
items: dict[pathlib.Path, PySide6.QtWidgets.QTreeWidgetItem]
def set_file( self, path: pathlib.Path, matches: list[Match], folder: str = '', package: str = '', expanded: bool | None = None) -> PySide6.QtWidgets.QTreeWidgetItem:
370    def set_file(self, path: Path, matches: list[Match], folder: str = "", package: str = "",
371                 expanded: bool | None = None) -> QTreeWidgetItem:
372        """Add a file's row (at the end) with its results, or fill it again."""
373        item = self.items.get(path)
374        new = item is None
375        if new:
376            item = self.items[path] = QTreeWidgetItem()
377            self.addTopLevelItem(item)
378        was_expanded = item.isExpanded()
379        item.takeChildren()
380        item.setText(0, f"{folder}/{path.name}" if folder else path.name)
381        item.setToolTip(0, str(path))
382        item.setData(0, PARTS_ROLE, ("file", path, path.name, folder, package, len(matches)))
383        item.setData(0, RESULT_ROLE, (str(path), None))
384        for found in matches:
385            child = QTreeWidgetItem([f"{found.line}: {found.before}{found.text}{found.after}"])
386            child.setToolTip(0, f"{path}:{found.line}")
387            child.setData(0, PARTS_ROLE, ("match", path, found))
388            child.setData(0, RESULT_ROLE, (str(path), found))
389            item.addChild(child)
390        item.setExpanded(expanded if expanded is not None else (True if new else was_expanded))
391        return item

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

def set_problems( self, path: pathlib.Path, problems: list[Problem], folder: str = '', package: str = ''):
393    def set_problems(self, path: Path, problems: list[Problem], folder: str = "", package: str = ""):
394        """Show a file's problems (instead of whatever was shown), keeping
395        the scroll position when it's the same file again."""
396        scroll = self.verticalScrollBar().value() if path in self.items else 0
397        self.clear()
398        self.items.clear()
399        self.forget(path)
400        item = self.items[path] = QTreeWidgetItem()
401        self.addTopLevelItem(item)
402        item.setText(0, path.name)
403        item.setToolTip(0, str(path))
404        item.setData(0, PARTS_ROLE, ("file", path, path.name, folder, package, len(problems)))
405        item.setData(0, RESULT_ROLE, (str(path), None))
406        for problem in problems:
407            child = QTreeWidgetItem([f"{problem.line}: {problem.badge} {problem.message}"])
408            lines = self.file_lines(path)
409            code = lines[problem.line - 1].strip() if 0 < problem.line <= len(lines) else ""
410            child.setToolTip(0, f"{problem.message}\n\nLine {problem.line}: {code[:200]}")
411            child.setData(0, PARTS_ROLE, ("problem", path, problem))
412            child.setData(0, RESULT_ROLE, (str(path), problem))
413            item.addChild(child)
414        item.setExpanded(True)
415        self.verticalScrollBar().setValue(scroll)

Show a file's problems (instead of whatever was shown), keeping the scroll position when it's the same file again.

def set_wrapping(self, on: bool):
417    def set_wrapping(self, on: bool):
418        """Rows as tall as their (wrapped) text: the Problems tab's messages
419        can be long (an AI review's). Off: one line each, quicker for long lists."""
420        self.setUniformRowHeights(not on)
421        self.setWordWrap(on)

Rows as tall as their (wrapped) text: the Problems tab's messages can be long (an AI review's). Off: one line each, quicker for long lists.

def resizeEvent(self, event):
423    def resizeEvent(self, event):
424        super().resizeEvent(event)
425        if not self.uniformRowHeights() and event.size().width() != event.oldSize().width():
426            self.doItemsLayout()  # (wrapped rows: new heights for the new width)

resizeEvent(self, event: PySide6.QtGui.QResizeEvent, /) -> None

def remove_file(self, path: pathlib.Path):
428    def remove_file(self, path: Path):
429        item = self.items.pop(path, None)
430        if item is not None:
431            self.takeTopLevelItem(self.indexOfTopLevelItem(item))
def clear_results(self):
433    def clear_results(self):
434        self.clear()
435        self.items.clear()
436        self._file_lines.clear()
437        self._line_formats.clear()
def toggle_collapse(self) -> bool:
439    def toggle_collapse(self) -> bool:
440        """Collapse every file, or expand them all if none is expanded.
441        Returns whether they are now expanded."""
442        items = [self.topLevelItem(i) for i in range(self.topLevelItemCount())]
443        expand = not any(item.isExpanded() for item in items)
444        for item in items:
445            item.setExpanded(expand)
446        return expand

Collapse every file, or expand them all if none is expanded. Returns whether they are now expanded.

def open_item(self, item: PySide6.QtWidgets.QTreeWidgetItem, _column: int = 0):
448    def open_item(self, item: QTreeWidgetItem, _column: int = 0):
449        """Open a result (a file's row: its first result)."""
450        data = item.data(0, RESULT_ROLE)
451        if not data:
452            return
453        path, found = data
454        if found is None:
455            if not item.childCount():
456                return
457            found = item.child(0).data(0, RESULT_ROLE)[1]
458        self.open_requested.emit(path, found.line, found.column, found.end_line, found.end_column)

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

def file_lines(self, path: pathlib.Path) -> list[str]:
461    def file_lines(self, path: Path) -> list[str]:
462        """A file's lines, as in the editor if it's open (kept for drawing)."""
463        lines = self._file_lines.get(path)
464        if lines is None:
465            text = self.text_for(path) if self.text_for is not None else None
466            if text is None:
467                text = search_text(path) or ""
468            lines = text.split("\n")
469            if len(self._file_lines) > 100:  # (keep the most recent files)
470                self._file_lines.pop(next(iter(self._file_lines)))
471            self._file_lines[path] = lines
472        return lines

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

def forget(self, path: pathlib.Path):
474    def forget(self, path: Path):
475        """A file changed: read and lex it again when its lines are drawn."""
476        self._file_lines.pop(path, None)
477        for key in [k for k in self._line_formats if k[0] == path]:
478            del self._line_formats[key]

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

def refresh_style(self):
481    def refresh_style(self):
482        """Use the editor's syntax colors (View → Syntax Colors), on its
483        background."""
484        self._style = get_style_by_name(hl.style_name(QApplication.palette()))
485        self._formats.clear()
486        self._line_formats.clear()
487        palette = self.palette()
488        palette.setColor(QPalette.Base, QColor(self._style.background_color))
489        palette.setColor(QPalette.Text, hl.style_text_color(self._style))
490        self.setPalette(palette)
491        self.viewport().update()

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

def line_formats(self, path: pathlib.Path, line: int) -> list | None:
493    def line_formats(self, path: Path, line: int) -> list | None:
494        """The syntax format of each column of a line (None: plain), lexed
495        with the lines before it for context (so a line inside a
496        multi-line string or comment is colored as one)."""
497        key = (path, line)
498        if key in self._line_formats:
499            return self._line_formats[key]
500        result = syntax_formats(self.file_lines(path), line, path, self._style, self._formats)
501        self._line_formats[key] = result
502        return result

The syntax format of each column of a line (None: plain), lexed with the lines before it for context (so a line inside a multi-line string or comment is colored as one).

staticMetaObject = PySide6.QtCore.QMetaObject("ResultTree" inherits "QTreeWidget": Methods: #103 type=Signal, signature=open_requested(QString,int,int,int,int), parameters=QString, int, int, int, int #104 type=Signal, signature=keep_requested(QString), parameters=QString )