vibefpmgui.build_monitor

Live table of the files fpm is compiling, fed from fpm's console output.

When its output is not a terminal, fpm prints one line when it starts a target and another when it finishes, e.g.::

[ 11%]            json_parameters.F90
[ 22%]            json_parameters.F90  done.

With an OpenMP-enabled fpm several files compile at once, so start and finish lines interleave. fpm prints "done." even for a file that failed to compile, so failures and warnings come from the compiler diagnostics instead (path:line:col: followed by Error:/Warning:) and from fpm's <ERROR> Compilation failed for object " app_main.f90.o " line.

fpm only echoes compiler output when a compile fails; otherwise it is kept in build/<compiler>_<hash>/<package>/<object>.o.log, so that log is read when a file finishes to pick up its warnings.

The table is drawn by RowDelegate: status badges (with a spinner while compiling), package tags, and per-file timing bars scaled to the slowest file. The summary chips above it filter the table by status.

   1"""Live table of the files fpm is compiling, fed from fpm's console output.
   2
   3When its output is not a terminal, fpm prints one line when it starts a
   4target and another when it finishes, e.g.::
   5
   6    [ 11%]            json_parameters.F90
   7    [ 22%]            json_parameters.F90  done.
   8
   9With an OpenMP-enabled fpm several files compile at once, so start and finish
  10lines interleave.  fpm prints "done." even for a file that failed to compile,
  11so failures and warnings come from the compiler diagnostics instead
  12(``path:line:col:`` followed by ``Error:``/``Warning:``) and from fpm's
  13``<ERROR> Compilation failed for object " app_main.f90.o "`` line.
  14
  15fpm only echoes compiler output when a compile fails; otherwise it is kept in
  16``build/<compiler>_<hash>/<package>/<object>.o.log``, so that log is read when
  17a file finishes to pick up its warnings.
  18
  19The table is drawn by `RowDelegate`: status badges (with a spinner
  20while compiling), package tags, and per-file timing bars scaled to the slowest
  21file.  The summary chips above it filter the table by status.
  22"""
  23
  24from __future__ import annotations
  25
  26import os
  27import re
  28import time
  29from dataclasses import dataclass, field
  30from pathlib import Path
  31from typing import ClassVar, NamedTuple
  32
  33from qtpy.QtCore import QEvent, QRectF, QSize, Qt, QTimer, Signal
  34from qtpy.QtGui import (
  35    QColor,
  36    QFont,
  37    QFontDatabase,
  38    QFontMetricsF,
  39    QLinearGradient,
  40    QPainter,
  41    QPalette,
  42    QPen,
  43)
  44from qtpy.QtWidgets import (
  45    QAbstractItemView,
  46    QApplication,
  47    QHBoxLayout,
  48    QHeaderView,
  49    QLabel,
  50    QMenu,
  51    QPushButton,
  52    QStyledItemDelegate,
  53    QTableWidget,
  54    QTableWidgetItem,
  55    QVBoxLayout,
  56    QWidget,
  57)
  58
  59from . import manifest as m
  60from .ansi import strip as strip_ansi
  61from .icons import make_icon
  62from .widgets import StatusLabel
  63
  64PROGRESS_RE = re.compile(r"^\[\s*(\d+)%\]\s+(\S+)(?:\s+(done|failed)\.)?\s*$")
  65# gfortran/gcc: "src/a.f90:3:15:" (message on later lines) or "a.c:3:5: error: ..."
  66# (An optional "C:" drive prefix, so Windows paths aren't cut at the drive colon.)
  67GNU_LOC_RE = re.compile(r"^((?:[A-Za-z]:)?[^:\s]+?):(\d+):(?:(\d+):)?\s*"
  68                        r"(?:(fatal error|error|warning)\b:?\s*(.*))?$", re.IGNORECASE)
  69# Intel: "src/a.f90(3): error #5082: ..."
  70INTEL_LOC_RE = re.compile(r"^((?:[A-Za-z]:)?[^:\s(]+?)\((\d+)\):\s*(error|warning|remark)\b[^:]*:\s*(.*)$",
  71                          re.IGNORECASE)
  72GNU_SEVERITY_RE = re.compile(r"^(Fatal Error|Error|Warning):\s*(.*)$")
  73# lfortran: "semantic error: ..." / "warning: ..." / "style suggestion: ...",
  74# then " --> src/a.f90:3:15" on the next line.
  75LFORTRAN_SEVERITY_RE = re.compile(r"^(?:[a-z]+ )?(error|warning|style suggestion):\s*(.*)$")
  76LFORTRAN_LOC_RE = re.compile(r"^\s*-->\s*((?:[A-Za-z]:)?.+?):(\d+)(?::\d+)?\s*$")
  77FAILED_OBJECT_RE = re.compile(r'Compilation failed for object\s+"\s*(.+?)\s*"')
  78UP_TO_DATE_RE = re.compile(r"Project is up to date")
  79
  80SOURCE_EXTS = {".f", ".for", ".ftn", ".f77", ".f90", ".f95", ".f03", ".f08", ".fpp",
  81               ".c", ".cc", ".cpp", ".cxx", ".h", ".inc"}
  82
  83COMPILING, DONE, WARNING, FAILED, ABORTED = "Compiling", "Done", "Warnings", "Failed", "Aborted"
  84STATUSES = (COMPILING, DONE, WARNING, FAILED, ABORTED)
  85STATUS_ICONS = {COMPILING: "⟳", DONE: "✓", WARNING: "⚠", FAILED: "✕", ABORTED: "–"}
  86LIBRARY = "library"  # package kind used for link targets
  87
  88PALETTES = {
  89    "light": {"bg": "#ffffff", "text": "#1f2328", "muted": "#8b949e", "line": "#eaeef2", "hover": "#f3f6f9",
  90                  "track": "#eaeef2", "accent1": "#4c6ef5", "accent2": "#15aabf",
  91                  "status": {COMPILING: "#e8a317", DONE: "#2f9e44", WARNING: "#e8590c",
  92                          FAILED: "#e03131", ABORTED: "#868e96"}},
  93    "dark": {"bg": "#1e1f22", "text": "#e6e8eb", "muted": "#8b929b", "line": "#2f3237", "hover": "#2a2d31",
  94                 "track": "#33363b", "accent1": "#748ffc", "accent2": "#3bc9db",
  95                 "status": {COMPILING: "#fcc419", DONE: "#51cf66", WARNING: "#ff922b",
  96                         FAILED: "#ff6b6b", ABORTED: "#868e96"}},
  97}
  98# Package-tag dot colors; the main window replaces these with the graph theme's.
  99DEFAULT_PACKAGE_COLORS = {m.ROOT: "#f6c85f", m.GIT: "#74c0fc", m.PATH: "#8ce99a",
 100                          m.REGISTRY: "#d7b8f3", m.META: "#f3c6a5", m.MISSING: "#ced4da",
 101                          LIBRARY: "#ced4da"}
 102
 103ROW_HEIGHT = 44  # at the normal text size; see scaled() (the Review tab: two lines a row)
 104COMPACT_ROW_HEIGHT = 30  # the Build, Tests and Run tabs: one line a row
 105
 106# Text-size factor set by the main window's View menu (1.0 = normal).
 107_ui_scale = 1.0
 108
 109
 110def set_ui_scale(factor: float):
 111    global _ui_scale
 112    _ui_scale = factor
 113
 114
 115def scaled(pixels: float) -> int:
 116    """A pixel size adjusted for the current text size."""
 117    return round(pixels * _ui_scale)
 118FLASH_SECONDS = 0.6  # a row briefly glows in its status color when it finishes
 119
 120
 121class Diagnostic(NamedTuple):
 122    file: str      # as the compiler printed it
 123    line: int
 124    severity: str  # "error", "fatal error", "warning" or "remark"
 125    message: str   # one-line summary including the location
 126
 127
 128class DiagnosticParser:
 129    """Turns compiler output, one line at a time, into `Diagnostic`."""
 130
 131    def __init__(self):
 132        self._location: tuple[str, int] | None = None  # gfortran prints the severity later
 133        self._pending: tuple[str, str] | None = None  # lfortran prints the location later
 134
 135    def feed(self, line: str) -> Diagnostic | None:
 136        match = LFORTRAN_SEVERITY_RE.match(line)
 137        if match:
 138            severity = "remark" if match.group(1) == "style suggestion" else match.group(1)
 139            self._pending = (severity, line)
 140            return None
 141        if self._pending:
 142            match = LFORTRAN_LOC_RE.match(line)
 143            if match:
 144                (severity, text), self._pending = self._pending, None
 145                return Diagnostic(match.group(1), int(match.group(2)), severity,
 146                                  f"{match.group(1)}:{match.group(2)}: {text}")
 147        match = INTEL_LOC_RE.match(line)
 148        if match:
 149            return Diagnostic(match.group(1), int(match.group(2)), match.group(3).lower(), line)
 150        match = GNU_LOC_RE.match(line)
 151        if match and Path(match.group(1)).suffix.lower() in SOURCE_EXTS:
 152            if match.group(4):  # severity on the same line (gcc/clang style)
 153                return Diagnostic(match.group(1), int(match.group(2)), match.group(4).lower(), line)
 154            self._location = (match.group(1), int(match.group(2)))
 155            return None
 156        match = GNU_SEVERITY_RE.match(line)
 157        if match and self._location:
 158            (file, number), self._location = self._location, None
 159            return Diagnostic(file, number, match.group(1).lower(), f"{file}:{number}: {line}")
 160        return None
 161
 162
 163LOG_READ_LIMIT = 1_000_000  # bytes of a compiler log read (half from each end)
 164
 165
 166def read_log_text(log: Path) -> str:
 167    """A compiler log's text. A huge one (an lfortran crash writes the whole
 168    compiled program, 100+ MB) is read as its start and end only, which is
 169    where the messages are; reading and showing all of it would freeze the app."""
 170    size = log.stat().st_size
 171    if size <= LOG_READ_LIMIT:
 172        return log.read_text(errors="replace")
 173    half = LOG_READ_LIMIT // 2
 174    with open(log, "rb") as f:
 175        head = f.read(half)
 176        f.seek(size - half)
 177        tail = f.read()
 178    head = head[:head.rfind(b"\n") + 1] or head  # whole lines only
 179    tail = tail[tail.find(b"\n") + 1:] or tail
 180    skipped = size - len(head) - len(tail)
 181    return (head.decode("utf-8", errors="replace")
 182            + f"\n[… {skipped / 1e6:.1f} MB of compiler output not shown — full log: {log} …]\n\n"
 183            + tail.decode("utf-8", errors="replace"))
 184
 185
 186def target_kind(name: str) -> str:
 187    suffix = Path(name).suffix.lower()
 188    if suffix in SOURCE_EXTS:
 189        return "Source"
 190    if suffix in (".a", ".so", ".dylib", ".lib", ".dll"):
 191        return "Library"
 192    return "Executable"
 193
 194
 195@dataclass
 196class Target:
 197    name: str
 198    row: int
 199    seq: int = 0  # original (start) order, for "unsorted"
 200    status: str = COMPILING
 201    started: float = field(default_factory=time.monotonic)
 202    finished: float | None = None
 203    messages: list[str] = field(default_factory=list)
 204    warnings: int = 0
 205    errors: int = 0
 206    candidates: list[tuple[str, Path]] = field(default_factory=list)  # (package, source)
 207    path: Path | None = None  # resolved source file
 208    log_text: str = ""  # the compiler's full output (fpm's .o.log)
 209    diagnostics: list[Diagnostic] = field(default_factory=list)
 210    package: str = ""
 211    package_kind: str = LIBRARY
 212
 213    def elapsed(self) -> float:
 214        return (self.finished or time.monotonic()) - self.started
 215
 216
 217def index_sources(graph: m.DependencyGraph | None) -> dict[str, list[tuple[str, Path]]]:
 218    """Map source file basename -> [(package, full path)] for every package
 219    on disk, so table rows can say which package a file belongs to."""
 220    index: dict[str, list[tuple[str, Path]]] = {}
 221    if graph is None:
 222        return index
 223    packages = [p for p in graph.packages.values()
 224                if p.directory and p.directory.is_dir() and p.kind != m.MISSING]
 225    package_dirs = {p.directory.resolve() for p in packages}
 226    for pkg in packages:
 227        index_package(index, pkg.name, pkg.directory, package_dirs)
 228    return index
 229
 230
 231def index_package(index: dict[str, list[tuple[str, Path]]], name: str, directory: Path,
 232                  package_dirs: set[Path] = frozenset()):
 233    """Add a package's source files to ``index`` (see index_sources)."""
 234    for dirpath, dirnames, filenames in os.walk(directory.resolve()):
 235        # Skip build trees (they hold other packages' sources), VCS dirs,
 236        # and nested packages (e.g. a path dependency inside the project),
 237        # which are indexed under their own name.
 238        dirnames[:] = [d for d in dirnames if d != "build" and not d.startswith(".")
 239                       and (Path(dirpath) / d).resolve() not in package_dirs]
 240        for f in filenames:
 241            if Path(f).suffix.lower() in SOURCE_EXTS:
 242                index.setdefault(f, []).append((name, Path(dirpath) / f))
 243
 244
 245# ----------------------------------------------------------------- drawing
 246def _tint(color: QColor, alpha: int) -> QColor:
 247    c = QColor(color)
 248    c.setAlpha(alpha)
 249    return c
 250
 251
 252def panel_stylesheet(object_name: str, pal: dict) -> str:
 253    """Flat table/header/scrollbar styling shared by the build and test panels."""
 254    n = f"#{object_name}"
 255    return f"""
 256        {n} {{ background: {pal['bg']}; }}
 257        {n} QLabel {{ color: {pal['text']}; background: transparent; }}
 258        {n} QTableView {{ background: {pal['bg']}; border: none; }}
 259        {n} QHeaderView {{ background: {pal['bg']}; }}
 260        {n} QAbstractScrollArea::corner {{ background: transparent; border: none; }}
 261        {n} QScrollBar:vertical {{ background: transparent; width: 8px; margin: 2px; }}
 262        {n} QScrollBar::handle:vertical {{ background: {pal['track']}; border-radius: 3px;
 263            min-height: 24px; }}
 264        {n} QScrollBar::handle:vertical:hover {{ background: {pal['muted']}; }}
 265        {n} QScrollBar:horizontal {{ background: transparent; height: 8px; margin: 2px; }}
 266        {n} QScrollBar::handle:horizontal {{ background: {pal['track']}; border-radius: 3px;
 267            min-width: 24px; }}
 268        {n} QScrollBar::handle:horizontal:hover {{ background: {pal['muted']}; }}
 269        {n} QScrollBar::add-line, {n} QScrollBar::sub-line,
 270        {n} QScrollBar::add-page, {n} QScrollBar::sub-page {{ background: none; height: 0; }}
 271        {n} QScrollBar::add-line:horizontal, {n} QScrollBar::sub-line:horizontal,
 272        {n} QScrollBar::add-page:horizontal, {n} QScrollBar::sub-page:horizontal {{ width: 0; }}
 273        {n} QHeaderView::section {{
 274            background: {pal['bg']}; color: {pal['muted']}; border: none;
 275            border-bottom: 1px solid {pal['line']}; padding: 6px 12px;
 276            font-size: {scaled(10)}px; font-weight: 600; }}
 277    """
 278
 279
 280def chip_stylesheet(pal: dict, color: str) -> str:
 281    """A rounded, tinted, checkable summary chip."""
 282    c = QColor(color)
 283    rgb = f"{c.red()},{c.green()},{c.blue()}"
 284    return f"""
 285        QPushButton {{ background: rgba({rgb},40); color: {pal['text']}; border: 1px solid transparent;
 286            border-radius: 10px; padding: 2px 10px; font-weight: 600; }}
 287        QPushButton:hover {{ background: rgba({rgb},70); }}
 288        QPushButton:checked {{ background: rgba({rgb},90); border: 1px solid rgb({rgb}); }}
 289    """
 290
 291
 292def app_is_light() -> bool:
 293    return QApplication.palette().color(QPalette.Base).lightness() >= 128
 294
 295
 296def paint_name_and_detail(p: QPainter, r: QRectF, name: str, detail: str, bold: QFont, small: QFont,
 297                          color: QColor, muted: QColor, detail_elide=Qt.ElideRight):
 298    """A row's name (bold) and, on the same line after it, its detail in
 299    small grey text (a file's folder, a test's exit code), each shortened
 300    to fit: the name keeps at least 60% of the room when both don't fit."""
 301    gap = 8
 302    name_metrics = QFontMetricsF(bold)
 303    name_width = name_metrics.horizontalAdvance(name)
 304    if detail:
 305        detail_width = QFontMetricsF(small).horizontalAdvance(detail)
 306        if name_width + gap + detail_width > r.width():
 307            name_width = min(name_width, max(r.width() * 0.6, r.width() - gap - detail_width))
 308    shown = name_metrics.elidedText(name, Qt.ElideMiddle, min(name_width, r.width()) + 1)
 309    p.setFont(bold)
 310    p.setPen(color)
 311    p.drawText(r, Qt.AlignLeft | Qt.AlignVCenter, shown)
 312    if not detail:
 313        return
 314    left = r.left() + name_metrics.horizontalAdvance(shown) + gap
 315    room = r.right() - left
 316    if room < 20:
 317        return
 318    p.setFont(small)
 319    p.setPen(muted)
 320    p.drawText(QRectF(left, r.top(), room, r.height()), Qt.AlignLeft | Qt.AlignVCenter,
 321               QFontMetricsF(small).elidedText(detail, detail_elide, room))
 322
 323
 324class RowDelegate(QStyledItemDelegate):
 325    """Paints every cell of the build table from its `Target`."""
 326
 327    def __init__(self, monitor: BuildMonitor):
 328        super().__init__(monitor.table)
 329        self.monitor = monitor
 330
 331    def sizeHint(self, option, index):
 332        return QSize(100, scaled(COMPACT_ROW_HEIGHT))
 333
 334    def paint(self, p: QPainter, option, index):
 335        mon = self.monitor
 336        if index.row() >= len(mon.targets):
 337            return
 338        target = mon.targets[index.row()]
 339        pal = mon.palette_colors
 340        r = QRectF(option.rect)
 341        p.save()
 342        p.setRenderHint(QPainter.Antialiasing)
 343        status_color = QColor(pal["status"][target.status])
 344
 345        if index.row() == mon.selected_row:
 346            p.fillRect(r, _tint(QColor(pal["accent1"]), 28))
 347            if index.column() == 0:
 348                p.fillRect(QRectF(r.left(), r.top() + 6, 3, r.height() - 12), QColor(pal["accent1"]))
 349        elif index.row() == mon.hover_row:
 350            p.fillRect(r, QColor(pal["hover"]))
 351        if target.finished is not None:  # fade-out flash when a file finishes
 352            age = time.monotonic() - target.finished
 353            if age < FLASH_SECONDS:
 354                p.fillRect(r, _tint(status_color, int(35 * (1 - age / FLASH_SECONDS))))
 355        p.setPen(QPen(QColor(pal["line"]), 1))
 356        p.drawLine(r.bottomLeft(), r.bottomRight())
 357
 358        small = QFont(option.font)
 359        small.setPointSizeF(max(option.font.pointSizeF() - 2, 7))
 360        column = index.column()
 361        if column == 0:
 362            self._paint_file(p, r, target, option.font, small, pal)
 363        elif column == 1:
 364            self._paint_package(p, r, target, small, pal)
 365        elif column == 2:
 366            self._paint_status(p, r, target, small, pal, status_color)
 367        else:
 368            self._paint_time(p, r, target, small, pal, status_color)
 369        p.restore()
 370
 371    def _paint_file(self, p, r, target, font, small, pal):
 372        bold = QFont(font)
 373        bold.setWeight(QFont.DemiBold)
 374        paint_name_and_detail(p, r.adjusted(14, 0, -6, 0), target.name, self.monitor.display_folder(target),
 375                              bold, small, QColor(pal["text"]), QColor(pal["muted"]), Qt.ElideLeft)
 376
 377    def _paint_package(self, p, r, target, small, pal):
 378        if not target.package:
 379            return
 380        color = QColor(self.monitor.package_colors.get(target.package_kind, pal["muted"]))
 381        fm = QFontMetricsF(small)
 382        text = fm.elidedText(target.package, Qt.ElideRight, r.width() - 46)
 383        pill = QRectF(r.left() + 8, r.center().y() - 9, fm.horizontalAdvance(text) + 28, 18)
 384        p.setPen(Qt.NoPen)
 385        p.setBrush(_tint(color, 50))
 386        p.drawRoundedRect(pill, 9, 9)
 387        p.setBrush(color)
 388        p.drawEllipse(QRectF(pill.left() + 7, pill.center().y() - 3, 6, 6))
 389        p.setFont(small)
 390        p.setPen(QColor(pal["text"]))
 391        p.drawText(pill.adjusted(17, 0, 0, 0), Qt.AlignVCenter | Qt.AlignLeft, text)
 392
 393    def _paint_status(self, p, r, target, small, pal, color):
 394        text = f"{target.warnings} warning{'s' * (target.warnings != 1)}" \
 395            if target.status == WARNING else target.status
 396        font = QFont(small)
 397        font.setWeight(QFont.DemiBold)
 398        pill = QRectF(r.left() + 8, r.center().y() - 10,
 399                      QFontMetricsF(font).horizontalAdvance(text) + 36, 20)
 400        p.setPen(Qt.NoPen)
 401        p.setBrush(_tint(color, 40))
 402        p.drawRoundedRect(pill, 10, 10)
 403        icon = QRectF(pill.left() + 8, pill.center().y() - 6, 12, 12)
 404        if target.status == COMPILING:  # spinning arc
 405            p.setPen(QPen(_tint(color, 70), 2))
 406            p.drawEllipse(icon)
 407            p.setPen(QPen(color, 2, Qt.SolidLine, Qt.RoundCap))
 408            p.drawArc(icon, int(-self.monitor.spinner_angle * 16), -110 * 16)
 409        else:
 410            p.setFont(font)
 411            p.setPen(color)
 412            p.drawText(icon.adjusted(-3, -3, 3, 3), Qt.AlignCenter, STATUS_ICONS[target.status])
 413        p.setFont(font)
 414        p.setPen(color.darker(115) if self.monitor.is_light else color)
 415        p.drawText(pill.adjusted(27, 0, -6, 0), Qt.AlignVCenter | Qt.AlignLeft, text)
 416
 417    def _paint_time(self, p, r, target, small, pal, color):
 418        elapsed = target.elapsed()
 419        slowest = self.monitor.slowest()
 420        track = QRectF(r.left() + 10, r.center().y() - 3, max(r.width() - 74, 10), 6)
 421        p.setPen(Qt.NoPen)
 422        p.setBrush(QColor(pal["track"]))
 423        p.drawRoundedRect(track, 3, 3)
 424        # Live bars use the status color; finished ones are grey.
 425        bar_color = color if target.status == COMPILING else QColor(pal["muted"])
 426        fraction = elapsed / slowest if slowest > 0 else 0
 427        p.setBrush(bar_color)
 428        p.drawRoundedRect(QRectF(track.left(), track.top(), max(track.width() * fraction, 3), 6), 3, 3)
 429        mono = QFontDatabase.systemFont(QFontDatabase.FixedFont)
 430        mono.setPointSizeF(small.pointSizeF())
 431        p.setFont(mono)
 432        p.setPen(QColor(pal["text"] if target.status == COMPILING else pal["muted"]))
 433        p.drawText(r.adjusted(0, 0, -12, 0), Qt.AlignVCenter | Qt.AlignRight, f"{elapsed:.1f} s")
 434
 435
 436class SortableTable:
 437    """Click-to-sort for the custom-drawn status tables.
 438
 439    The delegates paint row *n* from ``rows()[n]``, so instead of Qt's own
 440    sorting (which would reorder the items but not our list) the list itself
 441    is sorted and every row's items are rewritten. Clicking a header cycles
 442    through: sorted, reversed, original order. The order is kept while rows
 443    are added or change (re-sorted a few times a second at most).
 444
 445    Subclasses provide ``table``, ``COLUMNS``, ``rows()``, ``_sync_row(i)``
 446    and ``_reapply_filter()``, and may override ``_selected_object`` /
 447    ``_restore_selection`` to keep the selection on the same object.
 448    """
 449
 450    def _init_sorting(self, keys: dict, descending_first: set[int] = frozenset()):
 451        self._sort_keys = keys
 452        self._sort_desc_first = set(descending_first)
 453        self.sort_column = -1
 454        self.sort_descending = False
 455        self._sort_clicks = 0
 456        header = self.table.horizontalHeader()
 457        header.setSectionsClickable(True)
 458        header.sectionClicked.connect(self._header_clicked)
 459        self._sort_timer = QTimer(self)
 460        self._sort_timer.setSingleShot(True)
 461        self._sort_timer.setInterval(150)
 462        self._sort_timer.timeout.connect(self.apply_sort)
 463        for column in keys:
 464            item = self.table.horizontalHeaderItem(column)
 465            if item is not None:
 466                item.setToolTip("Click to sort (again to reverse, a third time for the original order)")
 467
 468    def _header_clicked(self, column: int):
 469        if column not in self._sort_keys:
 470            return
 471        if column != self.sort_column:
 472            self.sort_column = column
 473            self.sort_descending = column in self._sort_desc_first
 474            self._sort_clicks = 1
 475        elif self._sort_clicks == 1:
 476            self.sort_descending = not self.sort_descending
 477            self._sort_clicks = 2
 478        else:
 479            self.sort_column = -1  # back to the original order
 480            self._sort_clicks = 0
 481        self.apply_sort()
 482
 483    def schedule_sort(self):
 484        """Call when rows were added or changed; re-sorts shortly if sorted."""
 485        if self.sort_column >= 0 and not self._sort_timer.isActive():
 486            self._sort_timer.start()
 487
 488    def apply_sort(self):
 489        rows = self.rows()
 490        selected = self._selected_object()
 491        rows.sort(key=lambda o: getattr(o, "seq", 0))  # stable base: original order
 492        if self.sort_column >= 0:
 493            rows.sort(key=self._sort_keys[self.sort_column], reverse=self.sort_descending)
 494        for index, obj in enumerate(rows):
 495            if hasattr(obj, "row"):
 496                obj.row = index
 497            self._sync_row(index)
 498        self._restore_selection(selected)
 499        self._reapply_filter()
 500        self._update_sort_header()
 501        self.table.viewport().update()
 502
 503    def _update_sort_header(self):
 504        labels = []
 505        for column, name in enumerate(self.COLUMNS):
 506            if column == self.sort_column:
 507                name += " ▼" if self.sort_descending else " ▲"
 508            labels.append(name)
 509        self.table.setHorizontalHeaderLabels(labels)
 510
 511    def _selected_object(self):
 512        row = getattr(self, "selected_row", -1)
 513        rows = self.rows()
 514        return rows[row] if 0 <= row < len(rows) else None
 515
 516    def _restore_selection(self, obj):
 517        if hasattr(self, "selected_row"):
 518            self.selected_row = self.rows().index(obj) if obj is not None else -1
 519
 520
 521class ProgressLine(QWidget):
 522    """Thin rounded progress bar with a gradient fill. In "busy" mode
 523    (`set_busy`) it shows a segment sliding back and forth instead of
 524    a fill, for work whose length isn't known."""
 525
 526    SEGMENT = 0.28  # busy segment width, as a fraction of the bar
 527
 528    def __init__(self, monitor: BuildMonitor):
 529        super().__init__()
 530        self.monitor = monitor
 531        self.value = 0.0
 532        self.state = "running"  # running | ok | failed | stopped
 533        self.busy = False
 534        self._busy_started = 0.0
 535        self._busy_timer = QTimer(self)
 536        self._busy_timer.setInterval(16)
 537        self._busy_timer.timeout.connect(self.update)
 538        self.setFixedHeight(6)
 539
 540    def set(self, value: float, state: str | None = None):
 541        """Show a known fraction (0..1); ends busy mode."""
 542        self._stop_busy()
 543        self.value = max(0.0, min(1.0, value))
 544        if state:
 545            self.state = state
 546        self.update()
 547
 548    def set_busy(self, busy: bool = True):
 549        """Indeterminate mode: something is happening, amount unknown."""
 550        if busy and not self.busy:
 551            self.busy = True
 552            self.state = "running"
 553            self._busy_started = time.monotonic()
 554            self._busy_timer.start()
 555        elif not busy:
 556            self._stop_busy()
 557        self.update()
 558
 559    def _stop_busy(self):
 560        self.busy = False
 561        self._busy_timer.stop()
 562
 563    def paintEvent(self, _event):
 564        pal = self.monitor.palette_colors
 565        p = QPainter(self)
 566        p.setRenderHint(QPainter.Antialiasing)
 567        r = QRectF(self.rect())
 568        p.setPen(Qt.NoPen)
 569        p.setBrush(QColor(pal["track"]))
 570        p.drawRoundedRect(r, 3, 3)
 571        if self.busy:
 572            self._paint_busy(p, r, pal)
 573            return
 574        if self.value <= 0:
 575            return
 576        if self.state == "failed":
 577            a = b = QColor(pal["status"][FAILED])
 578        elif self.state == "stopped":
 579            a = b = QColor(pal["status"][ABORTED])
 580        elif self.state == "ok":
 581            a = b = QColor(pal["status"][DONE])
 582        else:
 583            a, b = QColor(pal["accent1"]), QColor(pal["accent2"])
 584        gradient = QLinearGradient(r.topLeft(), r.topRight())
 585        gradient.setColorAt(0, a)
 586        gradient.setColorAt(1, b)
 587        p.setBrush(gradient)
 588        p.drawRoundedRect(QRectF(r.left(), r.top(), max(r.width() * self.value, 6), r.height()), 3, 3)
 589
 590    def _paint_busy(self, p: QPainter, r: QRectF, pal: dict):
 591        # Ease back and forth across the track, ~1.6 s per sweep.
 592        t = ((time.monotonic() - self._busy_started) / 1.6) % 2.0
 593        t = t if t <= 1 else 2 - t
 594        t = t * t * (3 - 2 * t)  # smoothstep
 595        seg = r.width() * self.SEGMENT
 596        x = r.left() + (r.width() - seg) * t
 597        gradient = QLinearGradient(x, 0, x + seg, 0)
 598        a, b = QColor(pal["accent1"]), QColor(pal["accent2"])
 599        edge = QColor(a)
 600        edge.setAlpha(0)
 601        gradient.setColorAt(0, edge)
 602        gradient.setColorAt(0.3, a)
 603        gradient.setColorAt(0.7, b)
 604        end = QColor(b)
 605        end.setAlpha(0)
 606        gradient.setColorAt(1, end)
 607        p.setBrush(gradient)
 608        p.drawRoundedRect(QRectF(x, r.top(), seg, r.height()), 3, 3)
 609
 610
 611# ------------------------------------------------------------------ widget
 612STATUS_SORT = {FAILED: 0, WARNING: 1, COMPILING: 2, ABORTED: 3, DONE: 4}
 613
 614
 615class LogFinder:
 616    """Finds a source file's compile log, which fpm writes as
 617    ``build/<compiler>_<hash>/<package>/<source path>.o.log``: the path is
 618    relative to the project, with "/" as "_" (``src_utils.f90.o.log``,
 619    ``build_dependencies_fmin_src_fmin.F90.o.log``). There is a folder per
 620    compiler and flag combination, and a build may create new ones.
 621
 622    Called for each file a build finishes, so it doesn't list folders: it
 623    checks for the names the file's known paths give (one stat per folder).
 624    Only when that finds nothing (the path isn't known) does it list the
 625    folders, and each listing is kept until its folder changes."""
 626
 627    def __init__(self, project_dir: Path):
 628        self.project_dir = project_dir
 629        self._root = project_dir.resolve()
 630        self.build = project_dir / "build"
 631        self._folders: tuple[tuple, list[Path]] = ((), [])  # (what they were listed from, the folders)
 632        self._listings: dict[Path, tuple[int, dict[str, list[str]]]] = {}  # folder -> (its mtime, its logs by tail)
 633        self._resolved: dict[Path, Path] = {}  # (a source's path, resolved once)
 634        self.listed = 0  # folders listed (for the tests)
 635
 636    def folders(self) -> list[Path]:
 637        """``build/*_*/*``: listed again only when a folder is added or removed."""
 638        try:
 639            configs = sorted(e.path for e in os.scandir(self.build) if e.is_dir() and "_" in e.name)
 640            key = (self.build.stat().st_mtime_ns, *((c, os.stat(c).st_mtime_ns) for c in configs))
 641        except OSError:
 642            return []
 643        if key != self._folders[0]:
 644            folders = []
 645            for config in configs:
 646                try:
 647                    folders += [Path(e.path) for e in os.scandir(config) if e.is_dir()]
 648                except OSError:
 649                    continue
 650            self._folders = (key, folders)
 651        return self._folders[1]
 652
 653    def log_names(self, name: str, paths: list[Path]) -> list[str]:
 654        """The log names fpm would give a file with this name and these paths."""
 655        names = [f"{name}.o.log"]
 656        for path in paths:
 657            resolved = self._resolved.get(path)
 658            if resolved is None:
 659                resolved = self._resolved[path] = Path(path).resolve()
 660            try:
 661                rel = os.path.relpath(resolved, self._root)
 662            except ValueError:  # (Windows: another drive)
 663                continue
 664            names.append(rel.replace("\\", "_").replace("/", "_") + ".o.log")
 665        return names
 666
 667    def find(self, name: str, paths: list[Path], since: float) -> list[tuple[float, Path]]:
 668        """(modification time, log) of the logs for a source file written
 669        since ``since`` (the build's start)."""
 670        folders = self.folders()
 671        found = []
 672        log_names = self.log_names(name, paths)
 673        for folder in folders:
 674            for log_name in log_names:
 675                found += self._if_new(folder / log_name, since)
 676        if found:
 677            return found
 678        own = f"{name}.o.log"
 679        for folder in folders:  # (not found by its path, or that isn't known: any log of that file name)
 680            for log_name in self._listing(folder).get(_tail(own), ()):
 681                if log_name == own or log_name.endswith(f"_{own}"):
 682                    found += self._if_new(folder / log_name, since)
 683        return found
 684
 685    @staticmethod
 686    def _if_new(log: Path, since: float) -> list[tuple[float, Path]]:
 687        try:
 688            mtime = log.stat().st_mtime
 689        except OSError:
 690            return []
 691        return [(mtime, log)] if mtime >= since else []
 692
 693    def _listing(self, folder: Path) -> dict[str, list[str]]:
 694        """The folder's logs, by their names' tails (see `_tail`): a file's
 695        are under its own name's tail, so they're found without going
 696        through every log of a big build."""
 697        try:
 698            mtime = folder.stat().st_mtime_ns
 699        except OSError:
 700            return {}
 701        cached = self._listings.get(folder)
 702        if cached is None or cached[0] != mtime:
 703            self.listed += 1
 704            by_tail: dict[str, list[str]] = {}
 705            try:
 706                for entry in os.scandir(folder):
 707                    if entry.name.endswith(".o.log"):
 708                        by_tail.setdefault(_tail(entry.name), []).append(entry.name)
 709            except OSError:
 710                pass
 711            cached = self._listings[folder] = (mtime, by_tail)
 712        return cached[1]
 713
 714
 715def _tail(log_name: str) -> str:
 716    """A log's name after its last "_" (fpm's "src_sub_name.f90.o.log" →
 717    "name.f90.o.log"): "a_b.f90.o.log" and "src_a_b.f90.o.log" share theirs."""
 718    return log_name.rsplit("_", 1)[-1]
 719
 720
 721class TitleIcon(QLabel):
 722    """A panel's icon, the toolbar's for its command (see `icons.make_icon`),
 723    drawn in front of the panel's title at the title's height."""
 724
 725    def __init__(self, name: str, parent=None):
 726        super().__init__(parent)
 727        self.name = name
 728
 729    def draw(self, color: str, title: QLabel):
 730        size = round(title.fontMetrics().height() * 1.15)
 731        self.setPixmap(make_icon(self.name, color).pixmap(QSize(size, size)))
 732        self.setFixedSize(size, size)
 733
 734
 735class BuildMonitor(SortableTable, QWidget):
 736    """Per-file compile status: header with filter chips, a slim progress bar
 737    and a custom-drawn table."""
 738
 739    COLUMNS: ClassVar[list[str]] = ["FILE", "PACKAGE", "STATUS", "TIME"]
 740    # Row clicked: show that file's compiler messages (-1: show all output).
 741    details_requested = Signal(int)
 742    # Double-click: open (path, [Diagnostic]) in an editor.
 743    open_file_requested = Signal(str, list)
 744    # Right-click → Show Compile Command (source path).
 745    compile_command_requested = Signal(str)
 746
 747    def __init__(self, parent=None):
 748        super().__init__(parent)
 749        self.targets: list[Target] = []
 750        self.sources: dict[str, list[tuple[str, Path]]] = {}
 751        self.package_kinds: dict[str, str] = {}
 752        self.package_colors = dict(DEFAULT_PACKAGE_COLORS)
 753        self.diagnostics = DiagnosticParser()
 754        self.project_dir: Path | None = None
 755        self._expected: dict[Path, tuple[str, str]] = {}  # (see start and _index_new_dependencies)
 756        self._indexed: set[Path] = set()
 757        self._logs: LogFinder | None = None  # (where the compile logs are: see _read_log)
 758        self.hover_row = -1
 759        self.selected_row = -1
 760        self.spinner_angle = 0.0
 761        self.peak_parallel = 0
 762        self.filter_status: str | None = None
 763        self.description = ""
 764        self._build_started = 0.0  # wall clock, to compare with log mtimes
 765        self._started = 0.0        # monotonic, for the elapsed time
 766        self._finished_at: float | None = None
 767        self._percent = 0
 768        self._result = ""          # "", "ok", "failed", "stopped", "uptodate"
 769        self._active = False
 770        self.is_light = True
 771        self.palette_colors = PALETTES["light"]
 772
 773        self.setObjectName("BuildMonitor")
 774        self.setAttribute(Qt.WA_StyledBackground, True)
 775
 776        self.title_icon = TitleIcon("build")
 777        self.title = QLabel("Build")
 778        title_font = self.title.font()
 779        title_font.setPointSizeF(title_font.pointSizeF() + 2)
 780        title_font.setWeight(QFont.Bold)
 781        self.title.setFont(title_font)
 782        self.subtitle = StatusLabel()
 783        self.chips: dict[str, QPushButton] = {}
 784        header = QHBoxLayout()
 785        header.setSpacing(6)
 786        header.addWidget(self.title_icon)
 787        header.addWidget(self.title)
 788        header.addSpacing(4)
 789        header.addWidget(self.subtitle)
 790        header.addStretch(1)
 791        for status in STATUSES:
 792            chip = QPushButton()
 793            chip.setCheckable(True)
 794            chip.setCursor(Qt.PointingHandCursor)
 795            chip.setToolTip(f"Show only “{status}” files (click again to show all)")
 796            chip.clicked.connect(lambda checked, s=status: self.set_filter(s if checked else None))
 797            chip.hide()
 798            header.addWidget(chip)
 799            self.chips[status] = chip
 800
 801        self.progress = ProgressLine(self)
 802        self.progress_label = StatusLabel()
 803        progress_row = QHBoxLayout()
 804        progress_row.setSpacing(10)
 805        progress_row.addWidget(self.progress, 1)
 806        progress_row.addWidget(self.progress_label)
 807
 808        self.table = QTableWidget(0, len(self.COLUMNS))
 809        self.table.setHorizontalHeaderLabels(self.COLUMNS)
 810        self.table.setItemDelegate(RowDelegate(self))
 811        self.table.verticalHeader().setVisible(False)
 812        self.table.verticalHeader().setDefaultSectionSize(scaled(COMPACT_ROW_HEIGHT))
 813        self.table.setShowGrid(False)
 814        self.table.setFrameShape(QTableWidget.NoFrame)
 815        self.table.setEditTriggers(QAbstractItemView.NoEditTriggers)
 816        self.table.setSelectionMode(QAbstractItemView.NoSelection)
 817        self.table.setFocusPolicy(Qt.NoFocus)
 818        self.table.setMouseTracking(True)
 819        self.table.setVerticalScrollMode(QAbstractItemView.ScrollPerPixel)
 820        self.table.cellEntered.connect(self._set_hover)
 821        self.table.viewport().installEventFilter(self)
 822        self.table.cellClicked.connect(self._row_clicked)
 823        self.table.cellDoubleClicked.connect(self._open_row)
 824        self.table.setContextMenuPolicy(Qt.CustomContextMenu)
 825        self.table.customContextMenuRequested.connect(self._context_menu)
 826        hh = self.table.horizontalHeader()
 827        hh.setHighlightSections(False)
 828        hh.setDefaultAlignment(Qt.AlignLeft | Qt.AlignVCenter)
 829        hh.setSectionResizeMode(0, QHeaderView.Stretch)
 830        for column in (1, 2, 3):
 831            hh.setSectionResizeMode(column, QHeaderView.Fixed)
 832        self.table.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
 833        self._fit_columns()
 834        self._init_sorting({
 835            0: lambda t: t.name.lower(),
 836            1: lambda t: (t.package.lower(), t.name.lower()),
 837            2: lambda t: (STATUS_SORT.get(t.status, 9), -t.warnings),
 838            3: lambda t: t.elapsed(),
 839        }, descending_first={3})
 840
 841        layout = QVBoxLayout(self)
 842        layout.setContentsMargins(14, 10, 14, 6)
 843        layout.setSpacing(8)
 844        layout.addLayout(header)
 845        layout.addLayout(progress_row)
 846        layout.addWidget(self.table, 1)
 847        # Shown instead of the table (and its column headers) while it's empty.
 848        self.empty = QLabel()
 849        self.empty.setAlignment(Qt.AlignCenter)
 850        self.empty.setWordWrap(True)
 851        layout.addWidget(self.empty, 1)
 852
 853        self._header_pending = False  # (see _header_later)
 854        # Drives the spinner, the live times and the finish flash.
 855        self._timer = QTimer(self)
 856        self._timer.setInterval(50)
 857        self._timer.timeout.connect(self._tick)
 858
 859        self.refresh_palette()
 860        self.update_header()
 861
 862    # ------------------------------------------------------------- styling
 863    def refresh_palette(self):
 864        """Follow the application's light/dark palette."""
 865        self.is_light = app_is_light()
 866        self.palette_colors = PALETTES["light" if self.is_light else "dark"]
 867        self.setStyleSheet(panel_stylesheet(self.objectName(), self.palette_colors))
 868        self.empty.setStyleSheet(f"color: {self.palette_colors['muted']};")
 869        self.title_icon.draw(self.palette_colors["text"], self.title)
 870        self._style_chips()
 871        self.update_header()
 872        self.table.viewport().update()
 873        self.progress.update()
 874
 875    def set_package_colors(self, colors: dict[str, str]):
 876        """Use the dependency graph's colors for the package tags."""
 877        self.package_colors = {**DEFAULT_PACKAGE_COLORS, **colors}
 878        self.table.viewport().update()
 879
 880    def _style_chips(self):
 881        pal = self.palette_colors
 882        for status, chip in self.chips.items():
 883            chip.setStyleSheet(chip_stylesheet(pal, pal["status"][status]))
 884
 885    # ----------------------------------------------------------- lifecycle
 886    def start(self, graph: m.DependencyGraph | None, project_dir: Path, description: str = ""):
 887        self.targets.clear()
 888        self.selected_row = -1
 889        self.table.setRowCount(0)
 890        self.sources = index_sources(graph)
 891        self.package_kinds = {n: p.kind for n, p in graph.packages.items()} if graph else {}
 892        # Dependencies fpm downloads during this build aren't on disk yet: they're
 893        # indexed when they are (see _index_new_dependencies). The graph's names
 894        # and kinds for the folders it expects them in:
 895        self._expected = {p.directory.resolve(): (n, m.GIT if p.git else m.REGISTRY)
 896                          for n, p in (graph.packages.items() if graph else ()) if p.directory}
 897        self._indexed = {p.directory.resolve() for p in (graph.packages.values() if graph else ())
 898                         if p.directory and p.directory.is_dir() and p.kind != m.MISSING}
 899        self.diagnostics = DiagnosticParser()
 900        self.project_dir = project_dir
 901        self._logs = LogFinder(project_dir)  # (fresh for each build: new folders)
 902        self.description = description
 903        self.peak_parallel = 0
 904        self._build_started = time.time()
 905        self._started = time.monotonic()
 906        self._finished_at = None
 907        self._percent = 0
 908        self._result = ""
 909        self._active = True
 910        self.set_filter(None)
 911        self.progress.set(0, "running")
 912        self._timer.start()
 913        self.update_header()
 914
 915    def reset(self):
 916        """Back to the empty state (e.g. when another project is opened)."""
 917        self._timer.stop()
 918        self.targets.clear()
 919        self.selected_row = -1
 920        self.table.setRowCount(0)
 921        self.project_dir = None
 922        self.description = ""
 923        self.peak_parallel = 0
 924        self._started = 0.0
 925        self._finished_at = None
 926        self._percent = 0
 927        self._result = ""
 928        self._active = False
 929        self.set_filter(None)
 930        self.progress.set(0, "running")
 931        self.update_header()
 932
 933    @property
 934    def percent(self) -> int:
 935        """How far the build is (0–100)."""
 936        return self._percent
 937
 938    def finish(self, exit_code: int, stopped: bool = False):
 939        if not self._active:
 940            return
 941        self._active = False
 942        self._finished_at = time.monotonic()
 943        for t in self.targets:
 944            if t.status == COMPILING:
 945                # A sibling's failure (or Stop) kills files still compiling.
 946                t.finished = self._finished_at
 947                self._set_status(t, ABORTED if (stopped or exit_code != 0) else DONE)
 948        if stopped:
 949            self._result = "stopped"
 950        elif exit_code != 0:
 951            self._result = "failed"
 952        elif self._result != "uptodate" or self.targets:
 953            self._result = "ok"
 954            self._percent = 100
 955        self.progress.set(self._percent / 100, {"uptodate": "ok"}.get(self._result, self._result))
 956        self.update_header()
 957        self.table.viewport().update()
 958
 959    # ------------------------------------------------------------- parsing
 960    def feed_line(self, line: str):
 961        """Process one complete line of fpm output."""
 962        if not self._active:
 963            return
 964        line = line.rstrip()
 965        if not line:
 966            return
 967
 968        match = PROGRESS_RE.match(line)
 969        if match:
 970            percent, name, end = int(match.group(1)), match.group(2).strip(), match.group(3)
 971            self._percent = max(self._percent, percent)
 972            self.progress.set(self._percent / 100)
 973            if end:
 974                target = self._find(name, running=True) or self._add(name)
 975                if target.status == COMPILING:
 976                    target.finished = time.monotonic()
 977                    self._read_log(target)
 978                    self._set_status(target, FAILED if end == "failed" or target.errors
 979                                     else WARNING if target.warnings else DONE)
 980            else:
 981                self._add(name)
 982            self._header_later()
 983            return
 984
 985        match = FAILED_OBJECT_RE.search(line)
 986        if match:
 987            target = self._find_object(match.group(1))
 988            if target:
 989                self._mark_failed(target)
 990            self._header_later()
 991            return
 992
 993        if UP_TO_DATE_RE.search(line):
 994            self._percent = 100
 995            self._result = "uptodate"
 996            self.progress.set(1.0, "ok")
 997            self.update_header()
 998            return
 999
1000        self._parse_diagnostic(line)
1001
1002    def _parse_diagnostic(self, line: str):
1003        found = self.diagnostics.feed(line)
1004        if found:
1005            target = self._find(Path(found.file).name, running=False)
1006            if target:
1007                self._record(target, found)
1008
1009    def _read_log(self, target: Target):
1010        """Pick up diagnostics for a finished source file from fpm's log."""
1011        if target_kind(target.name) != "Source":
1012            return
1013        if self.project_dir is None:
1014            return
1015        if self._logs is None or self._logs.project_dir != self.project_dir:
1016            self._logs = LogFinder(self.project_dir)
1017        logs = self._logs.find(target.name, [path for _package, path in target.candidates], self._build_started)
1018        if not logs:
1019            return
1020        log = max(logs)[1]
1021        self._resolve_owner(target, log.name)
1022        try:
1023            text = read_log_text(log)
1024        except OSError:
1025            return
1026        target.log_text = text  # shown with its colors
1027        parser = DiagnosticParser()
1028        for line in strip_ansi(text).splitlines():
1029            found = parser.feed(line.rstrip())
1030            if found:
1031                # Diagnostics in included files still belong to this target.
1032                self._record(target, found)
1033
1034    def _resolve_owner(self, target: Target, log_name: str):
1035        """Several packages may have a file of this name; fpm names the log
1036        after the source path relative to the project ("/" -> "_"), which
1037        tells them apart."""
1038        if len(target.candidates) < 2 or self.project_dir is None:
1039            return
1040        for package, path in target.candidates:
1041            try:
1042                rel = os.path.relpath(path, self.project_dir)
1043            except ValueError:
1044                continue
1045            if log_name == rel.replace("\\", "_").replace("/", "_") + ".o.log":
1046                self._set_owner(target, package, path)
1047                return
1048
1049    def _record(self, target: Target, diag: Diagnostic):
1050        if diag.message in target.messages:  # fpm echoes the log on failure
1051            return
1052        target.messages.append(diag.message)
1053        target.diagnostics.append(diag)
1054        severity = diag.severity
1055        if severity in ("error", "fatal error"):
1056            target.errors += 1
1057            self._mark_failed(target)
1058        elif severity == "warning":
1059            target.warnings += 1
1060            if target.status in (DONE, WARNING):
1061                self._set_status(target, WARNING)
1062        self._update_tooltip(target)
1063        self._header_later()
1064
1065    # ------------------------------------------------------------- helpers
1066    def _find(self, name: str, running: bool) -> Target | None:
1067        """Most suitable row for a file name: the oldest one still compiling,
1068        otherwise (unless ``running``) the most recent one."""
1069        for t in self.targets:
1070            if t.name == name and t.status == COMPILING:
1071                return t
1072        if running:
1073            return None
1074        for t in reversed(self.targets):
1075            if t.name == name:
1076                return t
1077        return None
1078
1079    def _find_object(self, obj: str) -> Target | None:
1080        # fpm names objects after the source path with "/" -> "_", plus ".o".
1081        stem = obj.removesuffix(".o")
1082        candidates = [t for t in self.targets if stem == t.name or stem.endswith("_" + t.name)]
1083        return max(candidates, key=lambda t: len(t.name), default=None)
1084
1085    def _mark_failed(self, target: Target):
1086        if target.finished is None:
1087            target.finished = time.monotonic()
1088        self._set_status(target, FAILED)
1089
1090    def _add(self, name: str) -> Target:
1091        row = self.table.rowCount()
1092        self.table.insertRow(row)
1093        target = Target(name, row, seq=len(self.targets))
1094        self.targets.append(target)
1095        for column in range(len(self.COLUMNS)):
1096            self.table.setItem(row, column, QTableWidgetItem())
1097
1098        kind = target_kind(name)
1099        self.table.item(row, 0).setText(name)
1100        self.table.item(row, 0).setToolTip(kind)
1101        if kind == "Source":
1102            if name not in self.sources:
1103                self._index_new_dependencies()
1104            target.candidates = self.sources.get(name, [])
1105            if len(target.candidates) == 1:
1106                self._set_owner(target, *target.candidates[0])
1107            else:
1108                names = sorted({p for p, _ in target.candidates})
1109                target.package = ", ".join(names)
1110                target.package_kind = self.package_kinds.get(names[0], LIBRARY) if names else LIBRARY
1111        else:
1112            target.package = kind.lower()
1113            target.package_kind = LIBRARY
1114        self._set_status(target, COMPILING)
1115        running = sum(1 for t in self.targets if t.status == COMPILING)
1116        self.peak_parallel = max(self.peak_parallel, running)
1117        self._apply_filter(target)
1118        if not self.table.isRowHidden(row):
1119            self.table.scrollToItem(self.table.item(row, 0))
1120        self.schedule_sort()
1121        return target
1122
1123    def _index_new_dependencies(self):
1124        """Index the dependencies downloaded since the build started (in
1125        build/dependencies; their own dependencies too, which the graph
1126        didn't know of before): each folder once."""
1127        folder = self.project_dir / "build" / "dependencies" if self.project_dir else None
1128        if folder is None or not folder.is_dir():
1129            return
1130        for directory in sorted(folder.iterdir()):
1131            here = directory.resolve()
1132            if here in self._indexed or not (directory / "fpm.toml").is_file():
1133                continue
1134            self._indexed.add(here)
1135            name, kind = self._expected.get(here, (directory.name, m.GIT if (directory / ".git").exists()
1136                                                    else m.REGISTRY))
1137            index_package(self.sources, name, directory)
1138            if self.package_kinds.get(name, m.MISSING) == m.MISSING:
1139                self.package_kinds[name] = kind  # (its color: not "not downloaded" any more)
1140
1141    def _set_owner(self, target: Target, package: str, path: Path):
1142        target.package = package
1143        target.package_kind = self.package_kinds.get(package, LIBRARY)
1144        target.path = path
1145        item = self.table.item(target.row, 0)
1146        item.setData(Qt.UserRole, str(path))
1147        item.setToolTip(str(path))
1148        self.table.item(target.row, 1).setText(package)
1149
1150    def display_folder(self, target: Target) -> str:
1151        if target.path is None:
1152            return ""
1153        folder = target.path.parent
1154        if self.project_dir is not None:
1155            try:
1156                return os.path.relpath(folder, self.project_dir)
1157            except ValueError:
1158                pass
1159        return str(folder)
1160
1161    def _set_status(self, target: Target, status: str):
1162        target.status = status
1163        self.table.item(target.row, 2).setText(status)
1164        self._apply_filter(target)
1165        self.table.viewport().update()
1166        self.schedule_sort()
1167
1168    # SortableTable hooks
1169    def rows(self) -> list[Target]:
1170        return self.targets
1171
1172    def _sync_row(self, row: int):
1173        """Rewrite a row's items (text, tooltips, data) from its target."""
1174        t = self.targets[row]
1175        file_item = self.table.item(row, 0)
1176        file_item.setText(t.name)
1177        file_item.setToolTip(str(t.path) if t.path else target_kind(t.name))
1178        file_item.setData(Qt.UserRole, str(t.path) if t.path else None)
1179        self.table.item(row, 1).setText(t.package)
1180        status_item = self.table.item(row, 2)
1181        status_item.setText(t.status)
1182        status_item.setToolTip("")
1183        self._update_tooltip(t)
1184
1185    def _reapply_filter(self):
1186        for t in self.targets:
1187            self._apply_filter(t)
1188
1189    def _update_tooltip(self, target: Target):
1190        if target.messages:
1191            text = "\n".join(target.messages[:10])
1192            if len(target.messages) > 10:
1193                text += f"\n… {len(target.messages) - 10} more"
1194            self.table.item(target.row, 2).setToolTip(text)
1195
1196    def slowest(self) -> float:
1197        return max((t.elapsed() for t in self.targets), default=0.0)
1198
1199    # ------------------------------------------------------------- display
1200    def set_filter(self, status: str | None):
1201        self.filter_status = status
1202        for s, chip in self.chips.items():
1203            chip.setChecked(s == status)
1204        for t in self.targets:
1205            self._apply_filter(t)
1206
1207    def _apply_filter(self, target: Target):
1208        hide = self.filter_status is not None and target.status != self.filter_status
1209        self.table.setRowHidden(target.row, hide)
1210
1211    def counts(self) -> dict[str, int]:
1212        counts = {s: 0 for s in STATUSES}
1213        for t in self.targets:
1214            counts[t.status] += 1
1215        return counts
1216
1217    def _header_later(self):
1218        """The header brought up to date once the lines that have come in are
1219        all read (not after each: a big build's thousands of lines each
1220        counting every row and laying out the header's text held the window)."""
1221        if not self._header_pending:
1222            self._header_pending = True
1223            QTimer.singleShot(0, self, self.update_header)
1224
1225    def update_header(self):
1226        self._header_pending = False
1227        pal = self.palette_colors
1228        parts = [self.description] if self.description else []
1229        if self.peak_parallel > 1:
1230            parts.append(f"{self.peak_parallel} parallel")
1231        self.subtitle.setText(f"<span style='color:{pal['muted']}'>{' · '.join(parts)}</span>")
1232
1233        counts = self.counts()
1234        for status, chip in self.chips.items():
1235            chip.setText(f"{STATUS_ICONS[status]} {counts[status]}")
1236            chip.setVisible(counts[status] > 0)
1237        if self.filter_status and counts[self.filter_status] == 0:
1238            self.set_filter(None)
1239
1240        end = self._finished_at or time.monotonic()
1241        seconds = f"{end - self._started:.1f} s" if self._started else ""
1242        finished = counts[DONE] + counts[WARNING]
1243        muted = f"<span style='color:{pal['muted']}'>"
1244        if self._result in ("uptodate", "ok") and not self.targets:  # nothing needed compiling
1245            text = "<b>Up to date</b>"
1246        elif self._result == "ok":
1247            text = f"<b>Finished</b> {muted}· {finished} built · {seconds}</span>"
1248        elif self._result == "failed":
1249            text = f"<b>Failed at {self._percent}%</b> {muted}· {seconds}</span>"
1250        elif self._result == "stopped":
1251            text = f"<b>Stopped at {self._percent}%</b> {muted}· {seconds}</span>"
1252        elif self._active:
1253            text = f"<b>{self._percent}%</b> {muted}· {finished} done · {seconds}</span>"
1254        else:
1255            text = f"{muted}Run Build, Run or Test to see progress</span>"
1256        self.progress_label.setText(text)
1257        self._update_empty()
1258
1259    def _update_empty(self):
1260        """While nothing has been compiled, a message instead of the table."""
1261        if self.table.rowCount():
1262            message = ""
1263        elif self._result == "uptodate" or (self._result == "ok" and not self.targets):
1264            message = "Nothing needed compiling — the project is up to date."
1265        elif self._result in ("failed", "stopped"):
1266            message = "Nothing was compiled — see the output."
1267        elif self._active:
1268            message = "Waiting for fpm…"
1269        else:
1270            message = "Run Build, Run or Test to see each file fpm compiles here."
1271        if self.empty.text() != message:
1272            self.empty.setText(message)
1273        self.empty.setVisible(bool(message))
1274        self.table.setVisible(not message)
1275
1276    def _tick(self):
1277        self.spinner_angle = (self.spinner_angle + 18) % 360
1278        now = time.monotonic()
1279        flashing = any(t.finished is not None and now - t.finished < FLASH_SECONDS for t in self.targets)
1280        self.table.viewport().update()  # also clears the last flash frame
1281        self.update_header()
1282        if not (self._active or flashing):
1283            self._timer.stop()
1284
1285    def _set_hover(self, row: int, _column: int):
1286        if row != self.hover_row:
1287            self.hover_row = row
1288            self.table.viewport().update()
1289
1290    def eventFilter(self, obj, event):
1291        if obj is self.table.viewport():
1292            if event.type() == QEvent.Leave:
1293                self._set_hover(-1, 0)
1294            elif event.type() == QEvent.Resize:
1295                self._fit_columns()
1296        return super().eventFilter(obj, event)
1297
1298    def _fit_columns(self):
1299        """Share the width out without a horizontal scrollbar; drop the
1300        package column when space is tight (the file tooltip still has it)."""
1301        width = self.table.viewport().width()
1302        narrow = width < scaled(520)
1303        self.table.setColumnHidden(1, narrow)
1304        self.table.setColumnWidth(1, scaled(130))
1305        self.table.setColumnWidth(2, scaled(140 if width >= scaled(600) else 128))
1306        self.table.setColumnWidth(3, scaled(150 if width >= scaled(700) else 112))
1307
1308    def refresh_fonts(self):
1309        """Re-apply sizes after the application font changed."""
1310        title_font = QFont(QApplication.font())
1311        title_font.setPointSizeF(title_font.pointSizeF() + 2)
1312        title_font.setWeight(QFont.Bold)
1313        self.title.setFont(title_font)
1314        self.table.verticalHeader().setDefaultSectionSize(scaled(COMPACT_ROW_HEIGHT))
1315        self.refresh_palette()  # the stylesheet has pixel sizes
1316        self._fit_columns()
1317
1318    def _row_clicked(self, row: int, _column: int):
1319        """Click a file to see its compiler messages; click again for all output."""
1320        if row >= len(self.targets):
1321            return
1322        self.selected_row = -1 if self.selected_row == row else row
1323        self.table.viewport().update()
1324        self.details_requested.emit(self.selected_row)
1325
1326    def clear_selection(self):
1327        if self.selected_row != -1:
1328            self.selected_row = -1
1329            self.table.viewport().update()
1330
1331    def details_title(self, row: int) -> str:
1332        t = self.targets[row]
1333        what = {WARNING: f"{t.warnings} warning{'s' * (t.warnings != 1)}",
1334                FAILED: "errors"}.get(t.status, t.status.lower())
1335        package = f" ({t.package})" if t.package else ""
1336        return f"{t.name}{package} — {what}"
1337
1338    def details_text(self, row: int) -> str:
1339        """The compiler output for one file, as fpm logged it."""
1340        t = self.targets[row]
1341        if t.status == COMPILING:
1342            return "(still compiling)"
1343        if t.log_text.strip():
1344            return t.log_text
1345        if t.messages:
1346            return "\n".join(t.messages)
1347        return "No compiler messages for this file."
1348
1349    def _open_row(self, row: int, _column: int):
1350        """Double-click: open the source, at its first error or warning."""
1351        path = self.table.item(row, 0).data(Qt.UserRole)
1352        if path:
1353            self.open_file_requested.emit(path, self.diagnostics_for(row))
1354
1355    def _context_menu(self, pos):
1356        row = self.table.rowAt(pos.y())
1357        if not 0 <= row < len(self.targets):
1358            return
1359        target = self.targets[row]
1360        menu = QMenu(self)
1361        if target.path is not None:
1362            path = str(target.path)
1363            menu.addAction("Show Compile Command…", lambda: self.compile_command_requested.emit(path))
1364            menu.addAction("Open in Editor",
1365                           lambda: self.open_file_requested.emit(path, self.diagnostics_for(row)))
1366        menu.addAction("Show Compiler Messages", lambda: self._row_clicked(row, 0)
1367                       if self.selected_row != row else None)
1368        if menu.actions():
1369            menu.exec(self.table.viewport().mapToGlobal(pos))
1370
1371    def diagnostics_for(self, row: int) -> list[Diagnostic]:
1372        """Diagnostics that point into the row's own source file (errors first)."""
1373        t = self.targets[row]
1374        own = [d for d in t.diagnostics if Path(d.file).name == t.name]
1375        return sorted(own, key=lambda d: (d.severity == "warning", d.line))
PROGRESS_RE = re.compile('^\\[\\s*(\\d+)%\\]\\s+(\\S+)(?:\\s+(done|failed)\\.)?\\s*$')
GNU_LOC_RE = re.compile('^((?:[A-Za-z]:)?[^:\\s]+?):(\\d+):(?:(\\d+):)?\\s*(?:(fatal error|error|warning)\\b:?\\s*(.*))?$', re.IGNORECASE)
INTEL_LOC_RE = re.compile('^((?:[A-Za-z]:)?[^:\\s(]+?)\\((\\d+)\\):\\s*(error|warning|remark)\\b[^:]*:\\s*(.*)$', re.IGNORECASE)
GNU_SEVERITY_RE = re.compile('^(Fatal Error|Error|Warning):\\s*(.*)$')
LFORTRAN_SEVERITY_RE = re.compile('^(?:[a-z]+ )?(error|warning|style suggestion):\\s*(.*)$')
LFORTRAN_LOC_RE = re.compile('^\\s*-->\\s*((?:[A-Za-z]:)?.+?):(\\d+)(?::\\d+)?\\s*$')
FAILED_OBJECT_RE = re.compile('Compilation failed for object\\s+"\\s*(.+?)\\s*"')
UP_TO_DATE_RE = re.compile('Project is up to date')
SOURCE_EXTS = {'.cc', '.cpp', '.f95', '.for', '.f90', '.f77', '.f', '.h', '.cxx', '.inc', '.f08', '.fpp', '.c', '.ftn', '.f03'}
STATUSES = ('Compiling', 'Done', 'Warnings', 'Failed', 'Aborted')
STATUS_ICONS = {'Compiling': '⟳', 'Done': '✓', 'Warnings': '⚠', 'Failed': '✕', 'Aborted': '–'}
LIBRARY = 'library'
PALETTES = {'light': {'bg': '#ffffff', 'text': '#1f2328', 'muted': '#8b949e', 'line': '#eaeef2', 'hover': '#f3f6f9', 'track': '#eaeef2', 'accent1': '#4c6ef5', 'accent2': '#15aabf', 'status': {'Compiling': '#e8a317', 'Done': '#2f9e44', 'Warnings': '#e8590c', 'Failed': '#e03131', 'Aborted': '#868e96'}}, 'dark': {'bg': '#1e1f22', 'text': '#e6e8eb', 'muted': '#8b929b', 'line': '#2f3237', 'hover': '#2a2d31', 'track': '#33363b', 'accent1': '#748ffc', 'accent2': '#3bc9db', 'status': {'Compiling': '#fcc419', 'Done': '#51cf66', 'Warnings': '#ff922b', 'Failed': '#ff6b6b', 'Aborted': '#868e96'}}}
DEFAULT_PACKAGE_COLORS = {'root': '#f6c85f', 'git': '#74c0fc', 'path': '#8ce99a', 'registry': '#d7b8f3', 'metapackage': '#f3c6a5', 'missing': '#ced4da', 'library': '#ced4da'}
ROW_HEIGHT = 44
COMPACT_ROW_HEIGHT = 30
def set_ui_scale(factor: float):
111def set_ui_scale(factor: float):
112    global _ui_scale
113    _ui_scale = factor
def scaled(pixels: float) -> int:
116def scaled(pixels: float) -> int:
117    """A pixel size adjusted for the current text size."""
118    return round(pixels * _ui_scale)

A pixel size adjusted for the current text size.

FLASH_SECONDS = 0.6
class Diagnostic(typing.NamedTuple):
122class Diagnostic(NamedTuple):
123    file: str      # as the compiler printed it
124    line: int
125    severity: str  # "error", "fatal error", "warning" or "remark"
126    message: str   # one-line summary including the location

Diagnostic(file, line, severity, message)

Diagnostic(file: str, line: int, severity: str, message: str)

Create new instance of Diagnostic(file, line, severity, message)

file: str

Alias for field number 0

line: int

Alias for field number 1

severity: str

Alias for field number 2

message: str

Alias for field number 3

class DiagnosticParser:
129class DiagnosticParser:
130    """Turns compiler output, one line at a time, into `Diagnostic`."""
131
132    def __init__(self):
133        self._location: tuple[str, int] | None = None  # gfortran prints the severity later
134        self._pending: tuple[str, str] | None = None  # lfortran prints the location later
135
136    def feed(self, line: str) -> Diagnostic | None:
137        match = LFORTRAN_SEVERITY_RE.match(line)
138        if match:
139            severity = "remark" if match.group(1) == "style suggestion" else match.group(1)
140            self._pending = (severity, line)
141            return None
142        if self._pending:
143            match = LFORTRAN_LOC_RE.match(line)
144            if match:
145                (severity, text), self._pending = self._pending, None
146                return Diagnostic(match.group(1), int(match.group(2)), severity,
147                                  f"{match.group(1)}:{match.group(2)}: {text}")
148        match = INTEL_LOC_RE.match(line)
149        if match:
150            return Diagnostic(match.group(1), int(match.group(2)), match.group(3).lower(), line)
151        match = GNU_LOC_RE.match(line)
152        if match and Path(match.group(1)).suffix.lower() in SOURCE_EXTS:
153            if match.group(4):  # severity on the same line (gcc/clang style)
154                return Diagnostic(match.group(1), int(match.group(2)), match.group(4).lower(), line)
155            self._location = (match.group(1), int(match.group(2)))
156            return None
157        match = GNU_SEVERITY_RE.match(line)
158        if match and self._location:
159            (file, number), self._location = self._location, None
160            return Diagnostic(file, number, match.group(1).lower(), f"{file}:{number}: {line}")
161        return None

Turns compiler output, one line at a time, into Diagnostic.

def feed(self, line: str) -> Diagnostic | None:
136    def feed(self, line: str) -> Diagnostic | None:
137        match = LFORTRAN_SEVERITY_RE.match(line)
138        if match:
139            severity = "remark" if match.group(1) == "style suggestion" else match.group(1)
140            self._pending = (severity, line)
141            return None
142        if self._pending:
143            match = LFORTRAN_LOC_RE.match(line)
144            if match:
145                (severity, text), self._pending = self._pending, None
146                return Diagnostic(match.group(1), int(match.group(2)), severity,
147                                  f"{match.group(1)}:{match.group(2)}: {text}")
148        match = INTEL_LOC_RE.match(line)
149        if match:
150            return Diagnostic(match.group(1), int(match.group(2)), match.group(3).lower(), line)
151        match = GNU_LOC_RE.match(line)
152        if match and Path(match.group(1)).suffix.lower() in SOURCE_EXTS:
153            if match.group(4):  # severity on the same line (gcc/clang style)
154                return Diagnostic(match.group(1), int(match.group(2)), match.group(4).lower(), line)
155            self._location = (match.group(1), int(match.group(2)))
156            return None
157        match = GNU_SEVERITY_RE.match(line)
158        if match and self._location:
159            (file, number), self._location = self._location, None
160            return Diagnostic(file, number, match.group(1).lower(), f"{file}:{number}: {line}")
161        return None
LOG_READ_LIMIT = 1000000
def read_log_text(log: pathlib.Path) -> str:
167def read_log_text(log: Path) -> str:
168    """A compiler log's text. A huge one (an lfortran crash writes the whole
169    compiled program, 100+ MB) is read as its start and end only, which is
170    where the messages are; reading and showing all of it would freeze the app."""
171    size = log.stat().st_size
172    if size <= LOG_READ_LIMIT:
173        return log.read_text(errors="replace")
174    half = LOG_READ_LIMIT // 2
175    with open(log, "rb") as f:
176        head = f.read(half)
177        f.seek(size - half)
178        tail = f.read()
179    head = head[:head.rfind(b"\n") + 1] or head  # whole lines only
180    tail = tail[tail.find(b"\n") + 1:] or tail
181    skipped = size - len(head) - len(tail)
182    return (head.decode("utf-8", errors="replace")
183            + f"\n[… {skipped / 1e6:.1f} MB of compiler output not shown — full log: {log} …]\n\n"
184            + tail.decode("utf-8", errors="replace"))

A compiler log's text. A huge one (an lfortran crash writes the whole compiled program, 100+ MB) is read as its start and end only, which is where the messages are; reading and showing all of it would freeze the app.

def target_kind(name: str) -> str:
187def target_kind(name: str) -> str:
188    suffix = Path(name).suffix.lower()
189    if suffix in SOURCE_EXTS:
190        return "Source"
191    if suffix in (".a", ".so", ".dylib", ".lib", ".dll"):
192        return "Library"
193    return "Executable"
@dataclass
class Target:
196@dataclass
197class Target:
198    name: str
199    row: int
200    seq: int = 0  # original (start) order, for "unsorted"
201    status: str = COMPILING
202    started: float = field(default_factory=time.monotonic)
203    finished: float | None = None
204    messages: list[str] = field(default_factory=list)
205    warnings: int = 0
206    errors: int = 0
207    candidates: list[tuple[str, Path]] = field(default_factory=list)  # (package, source)
208    path: Path | None = None  # resolved source file
209    log_text: str = ""  # the compiler's full output (fpm's .o.log)
210    diagnostics: list[Diagnostic] = field(default_factory=list)
211    package: str = ""
212    package_kind: str = LIBRARY
213
214    def elapsed(self) -> float:
215        return (self.finished or time.monotonic()) - self.started
Target( name: str, row: int, seq: int = 0, status: str = 'Compiling', started: float = <factory>, finished: float | None = None, messages: list[str] = <factory>, warnings: int = 0, errors: int = 0, candidates: list[tuple[str, pathlib.Path]] = <factory>, path: pathlib.Path | None = None, log_text: str = '', diagnostics: list[Diagnostic] = <factory>, package: str = '', package_kind: str = 'library')
name: str
row: int
seq: int = 0
status: str = 'Compiling'
started: float
finished: float | None = None
messages: list[str]
warnings: int = 0
errors: int = 0
candidates: list[tuple[str, pathlib.Path]]
path: pathlib.Path | None = None
log_text: str = ''
diagnostics: list[Diagnostic]
package: str = ''
package_kind: str = 'library'
def elapsed(self) -> float:
214    def elapsed(self) -> float:
215        return (self.finished or time.monotonic()) - self.started
def index_sources( graph: vibefpmgui.manifest.DependencyGraph | None) -> dict[str, list[tuple[str, pathlib.Path]]]:
218def index_sources(graph: m.DependencyGraph | None) -> dict[str, list[tuple[str, Path]]]:
219    """Map source file basename -> [(package, full path)] for every package
220    on disk, so table rows can say which package a file belongs to."""
221    index: dict[str, list[tuple[str, Path]]] = {}
222    if graph is None:
223        return index
224    packages = [p for p in graph.packages.values()
225                if p.directory and p.directory.is_dir() and p.kind != m.MISSING]
226    package_dirs = {p.directory.resolve() for p in packages}
227    for pkg in packages:
228        index_package(index, pkg.name, pkg.directory, package_dirs)
229    return index

Map source file basename -> [(package, full path)] for every package on disk, so table rows can say which package a file belongs to.

def index_package( index: dict[str, list[tuple[str, pathlib.Path]]], name: str, directory: pathlib.Path, package_dirs: set[pathlib.Path] = frozenset()):
232def index_package(index: dict[str, list[tuple[str, Path]]], name: str, directory: Path,
233                  package_dirs: set[Path] = frozenset()):
234    """Add a package's source files to ``index`` (see index_sources)."""
235    for dirpath, dirnames, filenames in os.walk(directory.resolve()):
236        # Skip build trees (they hold other packages' sources), VCS dirs,
237        # and nested packages (e.g. a path dependency inside the project),
238        # which are indexed under their own name.
239        dirnames[:] = [d for d in dirnames if d != "build" and not d.startswith(".")
240                       and (Path(dirpath) / d).resolve() not in package_dirs]
241        for f in filenames:
242            if Path(f).suffix.lower() in SOURCE_EXTS:
243                index.setdefault(f, []).append((name, Path(dirpath) / f))

Add a package's source files to index (see index_sources).

def panel_stylesheet(object_name: str, pal: dict) -> str:
253def panel_stylesheet(object_name: str, pal: dict) -> str:
254    """Flat table/header/scrollbar styling shared by the build and test panels."""
255    n = f"#{object_name}"
256    return f"""
257        {n} {{ background: {pal['bg']}; }}
258        {n} QLabel {{ color: {pal['text']}; background: transparent; }}
259        {n} QTableView {{ background: {pal['bg']}; border: none; }}
260        {n} QHeaderView {{ background: {pal['bg']}; }}
261        {n} QAbstractScrollArea::corner {{ background: transparent; border: none; }}
262        {n} QScrollBar:vertical {{ background: transparent; width: 8px; margin: 2px; }}
263        {n} QScrollBar::handle:vertical {{ background: {pal['track']}; border-radius: 3px;
264            min-height: 24px; }}
265        {n} QScrollBar::handle:vertical:hover {{ background: {pal['muted']}; }}
266        {n} QScrollBar:horizontal {{ background: transparent; height: 8px; margin: 2px; }}
267        {n} QScrollBar::handle:horizontal {{ background: {pal['track']}; border-radius: 3px;
268            min-width: 24px; }}
269        {n} QScrollBar::handle:horizontal:hover {{ background: {pal['muted']}; }}
270        {n} QScrollBar::add-line, {n} QScrollBar::sub-line,
271        {n} QScrollBar::add-page, {n} QScrollBar::sub-page {{ background: none; height: 0; }}
272        {n} QScrollBar::add-line:horizontal, {n} QScrollBar::sub-line:horizontal,
273        {n} QScrollBar::add-page:horizontal, {n} QScrollBar::sub-page:horizontal {{ width: 0; }}
274        {n} QHeaderView::section {{
275            background: {pal['bg']}; color: {pal['muted']}; border: none;
276            border-bottom: 1px solid {pal['line']}; padding: 6px 12px;
277            font-size: {scaled(10)}px; font-weight: 600; }}
278    """

Flat table/header/scrollbar styling shared by the build and test panels.

def chip_stylesheet(pal: dict, color: str) -> str:
281def chip_stylesheet(pal: dict, color: str) -> str:
282    """A rounded, tinted, checkable summary chip."""
283    c = QColor(color)
284    rgb = f"{c.red()},{c.green()},{c.blue()}"
285    return f"""
286        QPushButton {{ background: rgba({rgb},40); color: {pal['text']}; border: 1px solid transparent;
287            border-radius: 10px; padding: 2px 10px; font-weight: 600; }}
288        QPushButton:hover {{ background: rgba({rgb},70); }}
289        QPushButton:checked {{ background: rgba({rgb},90); border: 1px solid rgb({rgb}); }}
290    """

A rounded, tinted, checkable summary chip.

def app_is_light() -> bool:
293def app_is_light() -> bool:
294    return QApplication.palette().color(QPalette.Base).lightness() >= 128
def paint_name_and_detail( p: PySide6.QtGui.QPainter, r: PySide6.QtCore.QRectF, name: str, detail: str, bold: PySide6.QtGui.QFont, small: PySide6.QtGui.QFont, color: PySide6.QtGui.QColor, muted: PySide6.QtGui.QColor, detail_elide=<TextElideMode.ElideRight: 1>):
297def paint_name_and_detail(p: QPainter, r: QRectF, name: str, detail: str, bold: QFont, small: QFont,
298                          color: QColor, muted: QColor, detail_elide=Qt.ElideRight):
299    """A row's name (bold) and, on the same line after it, its detail in
300    small grey text (a file's folder, a test's exit code), each shortened
301    to fit: the name keeps at least 60% of the room when both don't fit."""
302    gap = 8
303    name_metrics = QFontMetricsF(bold)
304    name_width = name_metrics.horizontalAdvance(name)
305    if detail:
306        detail_width = QFontMetricsF(small).horizontalAdvance(detail)
307        if name_width + gap + detail_width > r.width():
308            name_width = min(name_width, max(r.width() * 0.6, r.width() - gap - detail_width))
309    shown = name_metrics.elidedText(name, Qt.ElideMiddle, min(name_width, r.width()) + 1)
310    p.setFont(bold)
311    p.setPen(color)
312    p.drawText(r, Qt.AlignLeft | Qt.AlignVCenter, shown)
313    if not detail:
314        return
315    left = r.left() + name_metrics.horizontalAdvance(shown) + gap
316    room = r.right() - left
317    if room < 20:
318        return
319    p.setFont(small)
320    p.setPen(muted)
321    p.drawText(QRectF(left, r.top(), room, r.height()), Qt.AlignLeft | Qt.AlignVCenter,
322               QFontMetricsF(small).elidedText(detail, detail_elide, room))

A row's name (bold) and, on the same line after it, its detail in small grey text (a file's folder, a test's exit code), each shortened to fit: the name keeps at least 60% of the room when both don't fit.

class RowDelegate(PySide6.QtWidgets.QStyledItemDelegate):
325class RowDelegate(QStyledItemDelegate):
326    """Paints every cell of the build table from its `Target`."""
327
328    def __init__(self, monitor: BuildMonitor):
329        super().__init__(monitor.table)
330        self.monitor = monitor
331
332    def sizeHint(self, option, index):
333        return QSize(100, scaled(COMPACT_ROW_HEIGHT))
334
335    def paint(self, p: QPainter, option, index):
336        mon = self.monitor
337        if index.row() >= len(mon.targets):
338            return
339        target = mon.targets[index.row()]
340        pal = mon.palette_colors
341        r = QRectF(option.rect)
342        p.save()
343        p.setRenderHint(QPainter.Antialiasing)
344        status_color = QColor(pal["status"][target.status])
345
346        if index.row() == mon.selected_row:
347            p.fillRect(r, _tint(QColor(pal["accent1"]), 28))
348            if index.column() == 0:
349                p.fillRect(QRectF(r.left(), r.top() + 6, 3, r.height() - 12), QColor(pal["accent1"]))
350        elif index.row() == mon.hover_row:
351            p.fillRect(r, QColor(pal["hover"]))
352        if target.finished is not None:  # fade-out flash when a file finishes
353            age = time.monotonic() - target.finished
354            if age < FLASH_SECONDS:
355                p.fillRect(r, _tint(status_color, int(35 * (1 - age / FLASH_SECONDS))))
356        p.setPen(QPen(QColor(pal["line"]), 1))
357        p.drawLine(r.bottomLeft(), r.bottomRight())
358
359        small = QFont(option.font)
360        small.setPointSizeF(max(option.font.pointSizeF() - 2, 7))
361        column = index.column()
362        if column == 0:
363            self._paint_file(p, r, target, option.font, small, pal)
364        elif column == 1:
365            self._paint_package(p, r, target, small, pal)
366        elif column == 2:
367            self._paint_status(p, r, target, small, pal, status_color)
368        else:
369            self._paint_time(p, r, target, small, pal, status_color)
370        p.restore()
371
372    def _paint_file(self, p, r, target, font, small, pal):
373        bold = QFont(font)
374        bold.setWeight(QFont.DemiBold)
375        paint_name_and_detail(p, r.adjusted(14, 0, -6, 0), target.name, self.monitor.display_folder(target),
376                              bold, small, QColor(pal["text"]), QColor(pal["muted"]), Qt.ElideLeft)
377
378    def _paint_package(self, p, r, target, small, pal):
379        if not target.package:
380            return
381        color = QColor(self.monitor.package_colors.get(target.package_kind, pal["muted"]))
382        fm = QFontMetricsF(small)
383        text = fm.elidedText(target.package, Qt.ElideRight, r.width() - 46)
384        pill = QRectF(r.left() + 8, r.center().y() - 9, fm.horizontalAdvance(text) + 28, 18)
385        p.setPen(Qt.NoPen)
386        p.setBrush(_tint(color, 50))
387        p.drawRoundedRect(pill, 9, 9)
388        p.setBrush(color)
389        p.drawEllipse(QRectF(pill.left() + 7, pill.center().y() - 3, 6, 6))
390        p.setFont(small)
391        p.setPen(QColor(pal["text"]))
392        p.drawText(pill.adjusted(17, 0, 0, 0), Qt.AlignVCenter | Qt.AlignLeft, text)
393
394    def _paint_status(self, p, r, target, small, pal, color):
395        text = f"{target.warnings} warning{'s' * (target.warnings != 1)}" \
396            if target.status == WARNING else target.status
397        font = QFont(small)
398        font.setWeight(QFont.DemiBold)
399        pill = QRectF(r.left() + 8, r.center().y() - 10,
400                      QFontMetricsF(font).horizontalAdvance(text) + 36, 20)
401        p.setPen(Qt.NoPen)
402        p.setBrush(_tint(color, 40))
403        p.drawRoundedRect(pill, 10, 10)
404        icon = QRectF(pill.left() + 8, pill.center().y() - 6, 12, 12)
405        if target.status == COMPILING:  # spinning arc
406            p.setPen(QPen(_tint(color, 70), 2))
407            p.drawEllipse(icon)
408            p.setPen(QPen(color, 2, Qt.SolidLine, Qt.RoundCap))
409            p.drawArc(icon, int(-self.monitor.spinner_angle * 16), -110 * 16)
410        else:
411            p.setFont(font)
412            p.setPen(color)
413            p.drawText(icon.adjusted(-3, -3, 3, 3), Qt.AlignCenter, STATUS_ICONS[target.status])
414        p.setFont(font)
415        p.setPen(color.darker(115) if self.monitor.is_light else color)
416        p.drawText(pill.adjusted(27, 0, -6, 0), Qt.AlignVCenter | Qt.AlignLeft, text)
417
418    def _paint_time(self, p, r, target, small, pal, color):
419        elapsed = target.elapsed()
420        slowest = self.monitor.slowest()
421        track = QRectF(r.left() + 10, r.center().y() - 3, max(r.width() - 74, 10), 6)
422        p.setPen(Qt.NoPen)
423        p.setBrush(QColor(pal["track"]))
424        p.drawRoundedRect(track, 3, 3)
425        # Live bars use the status color; finished ones are grey.
426        bar_color = color if target.status == COMPILING else QColor(pal["muted"])
427        fraction = elapsed / slowest if slowest > 0 else 0
428        p.setBrush(bar_color)
429        p.drawRoundedRect(QRectF(track.left(), track.top(), max(track.width() * fraction, 3), 6), 3, 3)
430        mono = QFontDatabase.systemFont(QFontDatabase.FixedFont)
431        mono.setPointSizeF(small.pointSizeF())
432        p.setFont(mono)
433        p.setPen(QColor(pal["text"] if target.status == COMPILING else pal["muted"]))
434        p.drawText(r.adjusted(0, 0, -12, 0), Qt.AlignVCenter | Qt.AlignRight, f"{elapsed:.1f} s")

Paints every cell of the build table from its Target.

RowDelegate(monitor: BuildMonitor)
328    def __init__(self, monitor: BuildMonitor):
329        super().__init__(monitor.table)
330        self.monitor = monitor

__init__(self, /, parent: PySide6.QtCore.QObject | None = None) -> None

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

monitor
def sizeHint(self, option, index):
332    def sizeHint(self, option, index):
333        return QSize(100, scaled(COMPACT_ROW_HEIGHT))

sizeHint(self, option: PySide6.QtWidgets.QStyleOptionViewItem, index: PySide6.QtCore.QModelIndex | PySide6.QtCore.QPersistentModelIndex, /) -> PySide6.QtCore.QSize

def paint(self, p: PySide6.QtGui.QPainter, option, index):
335    def paint(self, p: QPainter, option, index):
336        mon = self.monitor
337        if index.row() >= len(mon.targets):
338            return
339        target = mon.targets[index.row()]
340        pal = mon.palette_colors
341        r = QRectF(option.rect)
342        p.save()
343        p.setRenderHint(QPainter.Antialiasing)
344        status_color = QColor(pal["status"][target.status])
345
346        if index.row() == mon.selected_row:
347            p.fillRect(r, _tint(QColor(pal["accent1"]), 28))
348            if index.column() == 0:
349                p.fillRect(QRectF(r.left(), r.top() + 6, 3, r.height() - 12), QColor(pal["accent1"]))
350        elif index.row() == mon.hover_row:
351            p.fillRect(r, QColor(pal["hover"]))
352        if target.finished is not None:  # fade-out flash when a file finishes
353            age = time.monotonic() - target.finished
354            if age < FLASH_SECONDS:
355                p.fillRect(r, _tint(status_color, int(35 * (1 - age / FLASH_SECONDS))))
356        p.setPen(QPen(QColor(pal["line"]), 1))
357        p.drawLine(r.bottomLeft(), r.bottomRight())
358
359        small = QFont(option.font)
360        small.setPointSizeF(max(option.font.pointSizeF() - 2, 7))
361        column = index.column()
362        if column == 0:
363            self._paint_file(p, r, target, option.font, small, pal)
364        elif column == 1:
365            self._paint_package(p, r, target, small, pal)
366        elif column == 2:
367            self._paint_status(p, r, target, small, pal, status_color)
368        else:
369            self._paint_time(p, r, target, small, pal, status_color)
370        p.restore()

paint(self, painter: PySide6.QtGui.QPainter, option: PySide6.QtWidgets.QStyleOptionViewItem, index: PySide6.QtCore.QModelIndex | PySide6.QtCore.QPersistentModelIndex, /) -> None

staticMetaObject = PySide6.QtCore.QMetaObject("RowDelegate" inherits "QStyledItemDelegate": )
class SortableTable:
437class SortableTable:
438    """Click-to-sort for the custom-drawn status tables.
439
440    The delegates paint row *n* from ``rows()[n]``, so instead of Qt's own
441    sorting (which would reorder the items but not our list) the list itself
442    is sorted and every row's items are rewritten. Clicking a header cycles
443    through: sorted, reversed, original order. The order is kept while rows
444    are added or change (re-sorted a few times a second at most).
445
446    Subclasses provide ``table``, ``COLUMNS``, ``rows()``, ``_sync_row(i)``
447    and ``_reapply_filter()``, and may override ``_selected_object`` /
448    ``_restore_selection`` to keep the selection on the same object.
449    """
450
451    def _init_sorting(self, keys: dict, descending_first: set[int] = frozenset()):
452        self._sort_keys = keys
453        self._sort_desc_first = set(descending_first)
454        self.sort_column = -1
455        self.sort_descending = False
456        self._sort_clicks = 0
457        header = self.table.horizontalHeader()
458        header.setSectionsClickable(True)
459        header.sectionClicked.connect(self._header_clicked)
460        self._sort_timer = QTimer(self)
461        self._sort_timer.setSingleShot(True)
462        self._sort_timer.setInterval(150)
463        self._sort_timer.timeout.connect(self.apply_sort)
464        for column in keys:
465            item = self.table.horizontalHeaderItem(column)
466            if item is not None:
467                item.setToolTip("Click to sort (again to reverse, a third time for the original order)")
468
469    def _header_clicked(self, column: int):
470        if column not in self._sort_keys:
471            return
472        if column != self.sort_column:
473            self.sort_column = column
474            self.sort_descending = column in self._sort_desc_first
475            self._sort_clicks = 1
476        elif self._sort_clicks == 1:
477            self.sort_descending = not self.sort_descending
478            self._sort_clicks = 2
479        else:
480            self.sort_column = -1  # back to the original order
481            self._sort_clicks = 0
482        self.apply_sort()
483
484    def schedule_sort(self):
485        """Call when rows were added or changed; re-sorts shortly if sorted."""
486        if self.sort_column >= 0 and not self._sort_timer.isActive():
487            self._sort_timer.start()
488
489    def apply_sort(self):
490        rows = self.rows()
491        selected = self._selected_object()
492        rows.sort(key=lambda o: getattr(o, "seq", 0))  # stable base: original order
493        if self.sort_column >= 0:
494            rows.sort(key=self._sort_keys[self.sort_column], reverse=self.sort_descending)
495        for index, obj in enumerate(rows):
496            if hasattr(obj, "row"):
497                obj.row = index
498            self._sync_row(index)
499        self._restore_selection(selected)
500        self._reapply_filter()
501        self._update_sort_header()
502        self.table.viewport().update()
503
504    def _update_sort_header(self):
505        labels = []
506        for column, name in enumerate(self.COLUMNS):
507            if column == self.sort_column:
508                name += " ▼" if self.sort_descending else " ▲"
509            labels.append(name)
510        self.table.setHorizontalHeaderLabels(labels)
511
512    def _selected_object(self):
513        row = getattr(self, "selected_row", -1)
514        rows = self.rows()
515        return rows[row] if 0 <= row < len(rows) else None
516
517    def _restore_selection(self, obj):
518        if hasattr(self, "selected_row"):
519            self.selected_row = self.rows().index(obj) if obj is not None else -1

Click-to-sort for the custom-drawn status tables.

The delegates paint row n from rows()[n], so instead of Qt's own sorting (which would reorder the items but not our list) the list itself is sorted and every row's items are rewritten. Clicking a header cycles through: sorted, reversed, original order. The order is kept while rows are added or change (re-sorted a few times a second at most).

Subclasses provide table, COLUMNS, rows(), _sync_row(i) and _reapply_filter(), and may override _selected_object / _restore_selection to keep the selection on the same object.

def schedule_sort(self):
484    def schedule_sort(self):
485        """Call when rows were added or changed; re-sorts shortly if sorted."""
486        if self.sort_column >= 0 and not self._sort_timer.isActive():
487            self._sort_timer.start()

Call when rows were added or changed; re-sorts shortly if sorted.

def apply_sort(self):
489    def apply_sort(self):
490        rows = self.rows()
491        selected = self._selected_object()
492        rows.sort(key=lambda o: getattr(o, "seq", 0))  # stable base: original order
493        if self.sort_column >= 0:
494            rows.sort(key=self._sort_keys[self.sort_column], reverse=self.sort_descending)
495        for index, obj in enumerate(rows):
496            if hasattr(obj, "row"):
497                obj.row = index
498            self._sync_row(index)
499        self._restore_selection(selected)
500        self._reapply_filter()
501        self._update_sort_header()
502        self.table.viewport().update()
class ProgressLine(PySide6.QtWidgets.QWidget):
522class ProgressLine(QWidget):
523    """Thin rounded progress bar with a gradient fill. In "busy" mode
524    (`set_busy`) it shows a segment sliding back and forth instead of
525    a fill, for work whose length isn't known."""
526
527    SEGMENT = 0.28  # busy segment width, as a fraction of the bar
528
529    def __init__(self, monitor: BuildMonitor):
530        super().__init__()
531        self.monitor = monitor
532        self.value = 0.0
533        self.state = "running"  # running | ok | failed | stopped
534        self.busy = False
535        self._busy_started = 0.0
536        self._busy_timer = QTimer(self)
537        self._busy_timer.setInterval(16)
538        self._busy_timer.timeout.connect(self.update)
539        self.setFixedHeight(6)
540
541    def set(self, value: float, state: str | None = None):
542        """Show a known fraction (0..1); ends busy mode."""
543        self._stop_busy()
544        self.value = max(0.0, min(1.0, value))
545        if state:
546            self.state = state
547        self.update()
548
549    def set_busy(self, busy: bool = True):
550        """Indeterminate mode: something is happening, amount unknown."""
551        if busy and not self.busy:
552            self.busy = True
553            self.state = "running"
554            self._busy_started = time.monotonic()
555            self._busy_timer.start()
556        elif not busy:
557            self._stop_busy()
558        self.update()
559
560    def _stop_busy(self):
561        self.busy = False
562        self._busy_timer.stop()
563
564    def paintEvent(self, _event):
565        pal = self.monitor.palette_colors
566        p = QPainter(self)
567        p.setRenderHint(QPainter.Antialiasing)
568        r = QRectF(self.rect())
569        p.setPen(Qt.NoPen)
570        p.setBrush(QColor(pal["track"]))
571        p.drawRoundedRect(r, 3, 3)
572        if self.busy:
573            self._paint_busy(p, r, pal)
574            return
575        if self.value <= 0:
576            return
577        if self.state == "failed":
578            a = b = QColor(pal["status"][FAILED])
579        elif self.state == "stopped":
580            a = b = QColor(pal["status"][ABORTED])
581        elif self.state == "ok":
582            a = b = QColor(pal["status"][DONE])
583        else:
584            a, b = QColor(pal["accent1"]), QColor(pal["accent2"])
585        gradient = QLinearGradient(r.topLeft(), r.topRight())
586        gradient.setColorAt(0, a)
587        gradient.setColorAt(1, b)
588        p.setBrush(gradient)
589        p.drawRoundedRect(QRectF(r.left(), r.top(), max(r.width() * self.value, 6), r.height()), 3, 3)
590
591    def _paint_busy(self, p: QPainter, r: QRectF, pal: dict):
592        # Ease back and forth across the track, ~1.6 s per sweep.
593        t = ((time.monotonic() - self._busy_started) / 1.6) % 2.0
594        t = t if t <= 1 else 2 - t
595        t = t * t * (3 - 2 * t)  # smoothstep
596        seg = r.width() * self.SEGMENT
597        x = r.left() + (r.width() - seg) * t
598        gradient = QLinearGradient(x, 0, x + seg, 0)
599        a, b = QColor(pal["accent1"]), QColor(pal["accent2"])
600        edge = QColor(a)
601        edge.setAlpha(0)
602        gradient.setColorAt(0, edge)
603        gradient.setColorAt(0.3, a)
604        gradient.setColorAt(0.7, b)
605        end = QColor(b)
606        end.setAlpha(0)
607        gradient.setColorAt(1, end)
608        p.setBrush(gradient)
609        p.drawRoundedRect(QRectF(x, r.top(), seg, r.height()), 3, 3)

Thin rounded progress bar with a gradient fill. In "busy" mode (set_busy) it shows a segment sliding back and forth instead of a fill, for work whose length isn't known.

ProgressLine(monitor: BuildMonitor)
529    def __init__(self, monitor: BuildMonitor):
530        super().__init__()
531        self.monitor = monitor
532        self.value = 0.0
533        self.state = "running"  # running | ok | failed | stopped
534        self.busy = False
535        self._busy_started = 0.0
536        self._busy_timer = QTimer(self)
537        self._busy_timer.setInterval(16)
538        self._busy_timer.timeout.connect(self.update)
539        self.setFixedHeight(6)

__init__(self, /, parent: PySide6.QtWidgets.QWidget | None = None, f: PySide6.QtCore.Qt.WindowType = Default(Qt.WindowFlags), *, modal: bool | None = None, windowModality: PySide6.QtCore.Qt.WindowModality | None = None, enabled: bool | None = None, geometry: PySide6.QtCore.QRect | None = None, frameGeometry: PySide6.QtCore.QRect | None = None, normalGeometry: PySide6.QtCore.QRect | None = None, x: int | None = None, y: int | None = None, pos: PySide6.QtCore.QPoint | None = None, frameSize: PySide6.QtCore.QSize | None = None, size: PySide6.QtCore.QSize | None = None, width: int | None = None, height: int | None = None, rect: PySide6.QtCore.QRect | None = None, childrenRect: PySide6.QtCore.QRect | None = None, childrenRegion: PySide6.QtGui.QRegion | None = None, sizePolicy: PySide6.QtWidgets.QSizePolicy | None = None, minimumSize: PySide6.QtCore.QSize | None = None, maximumSize: PySide6.QtCore.QSize | None = None, minimumWidth: int | None = None, minimumHeight: int | None = None, maximumWidth: int | None = None, maximumHeight: int | None = None, sizeIncrement: PySide6.QtCore.QSize | None = None, baseSize: PySide6.QtCore.QSize | None = None, palette: PySide6.QtGui.QPalette | None = None, font: PySide6.QtGui.QFont | None = None, cursor: PySide6.QtGui.QCursor | None = None, mouseTracking: bool | None = None, tabletTracking: bool | None = None, isActiveWindow: bool | None = None, focusPolicy: PySide6.QtCore.Qt.FocusPolicy | None = None, focus: bool | None = None, contextMenuPolicy: PySide6.QtCore.Qt.ContextMenuPolicy | None = None, updatesEnabled: bool | None = None, visible: bool | None = None, minimized: bool | None = None, maximized: bool | None = None, fullScreen: bool | None = None, sizeHint: PySide6.QtCore.QSize | None = None, minimumSizeHint: PySide6.QtCore.QSize | None = None, acceptDrops: bool | None = None, windowTitle: str | None = None, windowIcon: PySide6.QtGui.QIcon | None = None, windowIconText: str | None = None, windowOpacity: float | None = None, windowModified: bool | None = None, toolTip: str | None = None, toolTipDuration: int | None = None, statusTip: str | None = None, whatsThis: str | None = None, accessibleName: str | None = None, accessibleDescription: str | None = None, accessibleIdentifier: str | None = None, layoutDirection: PySide6.QtCore.Qt.LayoutDirection | None = None, autoFillBackground: bool | None = None, styleSheet: str | None = None, locale: PySide6.QtCore.QLocale | None = None, windowFilePath: str | None = None, inputMethodHints: PySide6.QtCore.Qt.InputMethodHint | None = None) -> None

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

SEGMENT = 0.28
monitor
value
state
busy
def set(self, value: float, state: str | None = None):
541    def set(self, value: float, state: str | None = None):
542        """Show a known fraction (0..1); ends busy mode."""
543        self._stop_busy()
544        self.value = max(0.0, min(1.0, value))
545        if state:
546            self.state = state
547        self.update()

Show a known fraction (0..1); ends busy mode.

def set_busy(self, busy: bool = True):
549    def set_busy(self, busy: bool = True):
550        """Indeterminate mode: something is happening, amount unknown."""
551        if busy and not self.busy:
552            self.busy = True
553            self.state = "running"
554            self._busy_started = time.monotonic()
555            self._busy_timer.start()
556        elif not busy:
557            self._stop_busy()
558        self.update()

Indeterminate mode: something is happening, amount unknown.

def paintEvent(self, _event):
564    def paintEvent(self, _event):
565        pal = self.monitor.palette_colors
566        p = QPainter(self)
567        p.setRenderHint(QPainter.Antialiasing)
568        r = QRectF(self.rect())
569        p.setPen(Qt.NoPen)
570        p.setBrush(QColor(pal["track"]))
571        p.drawRoundedRect(r, 3, 3)
572        if self.busy:
573            self._paint_busy(p, r, pal)
574            return
575        if self.value <= 0:
576            return
577        if self.state == "failed":
578            a = b = QColor(pal["status"][FAILED])
579        elif self.state == "stopped":
580            a = b = QColor(pal["status"][ABORTED])
581        elif self.state == "ok":
582            a = b = QColor(pal["status"][DONE])
583        else:
584            a, b = QColor(pal["accent1"]), QColor(pal["accent2"])
585        gradient = QLinearGradient(r.topLeft(), r.topRight())
586        gradient.setColorAt(0, a)
587        gradient.setColorAt(1, b)
588        p.setBrush(gradient)
589        p.drawRoundedRect(QRectF(r.left(), r.top(), max(r.width() * self.value, 6), r.height()), 3, 3)

paintEvent(self, event: PySide6.QtGui.QPaintEvent, /) -> None

staticMetaObject = PySide6.QtCore.QMetaObject("ProgressLine" inherits "QWidget": )
STATUS_SORT = {'Failed': 0, 'Warnings': 1, 'Compiling': 2, 'Aborted': 3, 'Done': 4}
class LogFinder:
616class LogFinder:
617    """Finds a source file's compile log, which fpm writes as
618    ``build/<compiler>_<hash>/<package>/<source path>.o.log``: the path is
619    relative to the project, with "/" as "_" (``src_utils.f90.o.log``,
620    ``build_dependencies_fmin_src_fmin.F90.o.log``). There is a folder per
621    compiler and flag combination, and a build may create new ones.
622
623    Called for each file a build finishes, so it doesn't list folders: it
624    checks for the names the file's known paths give (one stat per folder).
625    Only when that finds nothing (the path isn't known) does it list the
626    folders, and each listing is kept until its folder changes."""
627
628    def __init__(self, project_dir: Path):
629        self.project_dir = project_dir
630        self._root = project_dir.resolve()
631        self.build = project_dir / "build"
632        self._folders: tuple[tuple, list[Path]] = ((), [])  # (what they were listed from, the folders)
633        self._listings: dict[Path, tuple[int, dict[str, list[str]]]] = {}  # folder -> (its mtime, its logs by tail)
634        self._resolved: dict[Path, Path] = {}  # (a source's path, resolved once)
635        self.listed = 0  # folders listed (for the tests)
636
637    def folders(self) -> list[Path]:
638        """``build/*_*/*``: listed again only when a folder is added or removed."""
639        try:
640            configs = sorted(e.path for e in os.scandir(self.build) if e.is_dir() and "_" in e.name)
641            key = (self.build.stat().st_mtime_ns, *((c, os.stat(c).st_mtime_ns) for c in configs))
642        except OSError:
643            return []
644        if key != self._folders[0]:
645            folders = []
646            for config in configs:
647                try:
648                    folders += [Path(e.path) for e in os.scandir(config) if e.is_dir()]
649                except OSError:
650                    continue
651            self._folders = (key, folders)
652        return self._folders[1]
653
654    def log_names(self, name: str, paths: list[Path]) -> list[str]:
655        """The log names fpm would give a file with this name and these paths."""
656        names = [f"{name}.o.log"]
657        for path in paths:
658            resolved = self._resolved.get(path)
659            if resolved is None:
660                resolved = self._resolved[path] = Path(path).resolve()
661            try:
662                rel = os.path.relpath(resolved, self._root)
663            except ValueError:  # (Windows: another drive)
664                continue
665            names.append(rel.replace("\\", "_").replace("/", "_") + ".o.log")
666        return names
667
668    def find(self, name: str, paths: list[Path], since: float) -> list[tuple[float, Path]]:
669        """(modification time, log) of the logs for a source file written
670        since ``since`` (the build's start)."""
671        folders = self.folders()
672        found = []
673        log_names = self.log_names(name, paths)
674        for folder in folders:
675            for log_name in log_names:
676                found += self._if_new(folder / log_name, since)
677        if found:
678            return found
679        own = f"{name}.o.log"
680        for folder in folders:  # (not found by its path, or that isn't known: any log of that file name)
681            for log_name in self._listing(folder).get(_tail(own), ()):
682                if log_name == own or log_name.endswith(f"_{own}"):
683                    found += self._if_new(folder / log_name, since)
684        return found
685
686    @staticmethod
687    def _if_new(log: Path, since: float) -> list[tuple[float, Path]]:
688        try:
689            mtime = log.stat().st_mtime
690        except OSError:
691            return []
692        return [(mtime, log)] if mtime >= since else []
693
694    def _listing(self, folder: Path) -> dict[str, list[str]]:
695        """The folder's logs, by their names' tails (see `_tail`): a file's
696        are under its own name's tail, so they're found without going
697        through every log of a big build."""
698        try:
699            mtime = folder.stat().st_mtime_ns
700        except OSError:
701            return {}
702        cached = self._listings.get(folder)
703        if cached is None or cached[0] != mtime:
704            self.listed += 1
705            by_tail: dict[str, list[str]] = {}
706            try:
707                for entry in os.scandir(folder):
708                    if entry.name.endswith(".o.log"):
709                        by_tail.setdefault(_tail(entry.name), []).append(entry.name)
710            except OSError:
711                pass
712            cached = self._listings[folder] = (mtime, by_tail)
713        return cached[1]

Finds a source file's compile log, which fpm writes as build/<compiler>_<hash>/<package>/<source path>.o.log: the path is relative to the project, with "/" as "_" (src_utils.f90.o.log, build_dependencies_fmin_src_fmin.F90.o.log). There is a folder per compiler and flag combination, and a build may create new ones.

Called for each file a build finishes, so it doesn't list folders: it checks for the names the file's known paths give (one stat per folder). Only when that finds nothing (the path isn't known) does it list the folders, and each listing is kept until its folder changes.

LogFinder(project_dir: pathlib.Path)
628    def __init__(self, project_dir: Path):
629        self.project_dir = project_dir
630        self._root = project_dir.resolve()
631        self.build = project_dir / "build"
632        self._folders: tuple[tuple, list[Path]] = ((), [])  # (what they were listed from, the folders)
633        self._listings: dict[Path, tuple[int, dict[str, list[str]]]] = {}  # folder -> (its mtime, its logs by tail)
634        self._resolved: dict[Path, Path] = {}  # (a source's path, resolved once)
635        self.listed = 0  # folders listed (for the tests)
project_dir
build
listed
def folders(self) -> list[pathlib.Path]:
637    def folders(self) -> list[Path]:
638        """``build/*_*/*``: listed again only when a folder is added or removed."""
639        try:
640            configs = sorted(e.path for e in os.scandir(self.build) if e.is_dir() and "_" in e.name)
641            key = (self.build.stat().st_mtime_ns, *((c, os.stat(c).st_mtime_ns) for c in configs))
642        except OSError:
643            return []
644        if key != self._folders[0]:
645            folders = []
646            for config in configs:
647                try:
648                    folders += [Path(e.path) for e in os.scandir(config) if e.is_dir()]
649                except OSError:
650                    continue
651            self._folders = (key, folders)
652        return self._folders[1]

build/*_*/*: listed again only when a folder is added or removed.

def log_names(self, name: str, paths: list[pathlib.Path]) -> list[str]:
654    def log_names(self, name: str, paths: list[Path]) -> list[str]:
655        """The log names fpm would give a file with this name and these paths."""
656        names = [f"{name}.o.log"]
657        for path in paths:
658            resolved = self._resolved.get(path)
659            if resolved is None:
660                resolved = self._resolved[path] = Path(path).resolve()
661            try:
662                rel = os.path.relpath(resolved, self._root)
663            except ValueError:  # (Windows: another drive)
664                continue
665            names.append(rel.replace("\\", "_").replace("/", "_") + ".o.log")
666        return names

The log names fpm would give a file with this name and these paths.

def find( self, name: str, paths: list[pathlib.Path], since: float) -> list[tuple[float, pathlib.Path]]:
668    def find(self, name: str, paths: list[Path], since: float) -> list[tuple[float, Path]]:
669        """(modification time, log) of the logs for a source file written
670        since ``since`` (the build's start)."""
671        folders = self.folders()
672        found = []
673        log_names = self.log_names(name, paths)
674        for folder in folders:
675            for log_name in log_names:
676                found += self._if_new(folder / log_name, since)
677        if found:
678            return found
679        own = f"{name}.o.log"
680        for folder in folders:  # (not found by its path, or that isn't known: any log of that file name)
681            for log_name in self._listing(folder).get(_tail(own), ()):
682                if log_name == own or log_name.endswith(f"_{own}"):
683                    found += self._if_new(folder / log_name, since)
684        return found

(modification time, log) of the logs for a source file written since since (the build's start).

class TitleIcon(PySide6.QtWidgets.QLabel):
722class TitleIcon(QLabel):
723    """A panel's icon, the toolbar's for its command (see `icons.make_icon`),
724    drawn in front of the panel's title at the title's height."""
725
726    def __init__(self, name: str, parent=None):
727        super().__init__(parent)
728        self.name = name
729
730    def draw(self, color: str, title: QLabel):
731        size = round(title.fontMetrics().height() * 1.15)
732        self.setPixmap(make_icon(self.name, color).pixmap(QSize(size, size)))
733        self.setFixedSize(size, size)

A panel's icon, the toolbar's for its command (see icons.make_icon), drawn in front of the panel's title at the title's height.

TitleIcon(name: str, parent=None)
726    def __init__(self, name: str, parent=None):
727        super().__init__(parent)
728        self.name = name

__init__(self, text: str, /, parent: PySide6.QtWidgets.QWidget | None = None, f: PySide6.QtCore.Qt.WindowType = Default(Qt.WindowFlags), *, textFormat: PySide6.QtCore.Qt.TextFormat | None = None, pixmap: PySide6.QtGui.QPixmap | None = None, scaledContents: bool | None = None, alignment: PySide6.QtCore.Qt.AlignmentFlag | None = None, wordWrap: bool | None = None, margin: int | None = None, indent: int | None = None, openExternalLinks: bool | None = None, textInteractionFlags: PySide6.QtCore.Qt.TextInteractionFlag | None = None, hasSelectedText: bool | None = None, selectedText: str | None = None) -> None __init__(self, /, parent: PySide6.QtWidgets.QWidget | None = None, f: PySide6.QtCore.Qt.WindowType = Default(Qt.WindowFlags), *, text: str | None = None, textFormat: PySide6.QtCore.Qt.TextFormat | None = None, pixmap: PySide6.QtGui.QPixmap | None = None, scaledContents: bool | None = None, alignment: PySide6.QtCore.Qt.AlignmentFlag | None = None, wordWrap: bool | None = None, margin: int | None = None, indent: int | None = None, openExternalLinks: bool | None = None, textInteractionFlags: PySide6.QtCore.Qt.TextInteractionFlag | None = None, hasSelectedText: bool | None = None, selectedText: str | None = None) -> None

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

name
def draw(self, color: str, title: PySide6.QtWidgets.QLabel):
730    def draw(self, color: str, title: QLabel):
731        size = round(title.fontMetrics().height() * 1.15)
732        self.setPixmap(make_icon(self.name, color).pixmap(QSize(size, size)))
733        self.setFixedSize(size, size)
staticMetaObject = PySide6.QtCore.QMetaObject("TitleIcon" inherits "QLabel": )
class BuildMonitor(SortableTable, PySide6.QtWidgets.QWidget):
 736class BuildMonitor(SortableTable, QWidget):
 737    """Per-file compile status: header with filter chips, a slim progress bar
 738    and a custom-drawn table."""
 739
 740    COLUMNS: ClassVar[list[str]] = ["FILE", "PACKAGE", "STATUS", "TIME"]
 741    # Row clicked: show that file's compiler messages (-1: show all output).
 742    details_requested = Signal(int)
 743    # Double-click: open (path, [Diagnostic]) in an editor.
 744    open_file_requested = Signal(str, list)
 745    # Right-click → Show Compile Command (source path).
 746    compile_command_requested = Signal(str)
 747
 748    def __init__(self, parent=None):
 749        super().__init__(parent)
 750        self.targets: list[Target] = []
 751        self.sources: dict[str, list[tuple[str, Path]]] = {}
 752        self.package_kinds: dict[str, str] = {}
 753        self.package_colors = dict(DEFAULT_PACKAGE_COLORS)
 754        self.diagnostics = DiagnosticParser()
 755        self.project_dir: Path | None = None
 756        self._expected: dict[Path, tuple[str, str]] = {}  # (see start and _index_new_dependencies)
 757        self._indexed: set[Path] = set()
 758        self._logs: LogFinder | None = None  # (where the compile logs are: see _read_log)
 759        self.hover_row = -1
 760        self.selected_row = -1
 761        self.spinner_angle = 0.0
 762        self.peak_parallel = 0
 763        self.filter_status: str | None = None
 764        self.description = ""
 765        self._build_started = 0.0  # wall clock, to compare with log mtimes
 766        self._started = 0.0        # monotonic, for the elapsed time
 767        self._finished_at: float | None = None
 768        self._percent = 0
 769        self._result = ""          # "", "ok", "failed", "stopped", "uptodate"
 770        self._active = False
 771        self.is_light = True
 772        self.palette_colors = PALETTES["light"]
 773
 774        self.setObjectName("BuildMonitor")
 775        self.setAttribute(Qt.WA_StyledBackground, True)
 776
 777        self.title_icon = TitleIcon("build")
 778        self.title = QLabel("Build")
 779        title_font = self.title.font()
 780        title_font.setPointSizeF(title_font.pointSizeF() + 2)
 781        title_font.setWeight(QFont.Bold)
 782        self.title.setFont(title_font)
 783        self.subtitle = StatusLabel()
 784        self.chips: dict[str, QPushButton] = {}
 785        header = QHBoxLayout()
 786        header.setSpacing(6)
 787        header.addWidget(self.title_icon)
 788        header.addWidget(self.title)
 789        header.addSpacing(4)
 790        header.addWidget(self.subtitle)
 791        header.addStretch(1)
 792        for status in STATUSES:
 793            chip = QPushButton()
 794            chip.setCheckable(True)
 795            chip.setCursor(Qt.PointingHandCursor)
 796            chip.setToolTip(f"Show only “{status}” files (click again to show all)")
 797            chip.clicked.connect(lambda checked, s=status: self.set_filter(s if checked else None))
 798            chip.hide()
 799            header.addWidget(chip)
 800            self.chips[status] = chip
 801
 802        self.progress = ProgressLine(self)
 803        self.progress_label = StatusLabel()
 804        progress_row = QHBoxLayout()
 805        progress_row.setSpacing(10)
 806        progress_row.addWidget(self.progress, 1)
 807        progress_row.addWidget(self.progress_label)
 808
 809        self.table = QTableWidget(0, len(self.COLUMNS))
 810        self.table.setHorizontalHeaderLabels(self.COLUMNS)
 811        self.table.setItemDelegate(RowDelegate(self))
 812        self.table.verticalHeader().setVisible(False)
 813        self.table.verticalHeader().setDefaultSectionSize(scaled(COMPACT_ROW_HEIGHT))
 814        self.table.setShowGrid(False)
 815        self.table.setFrameShape(QTableWidget.NoFrame)
 816        self.table.setEditTriggers(QAbstractItemView.NoEditTriggers)
 817        self.table.setSelectionMode(QAbstractItemView.NoSelection)
 818        self.table.setFocusPolicy(Qt.NoFocus)
 819        self.table.setMouseTracking(True)
 820        self.table.setVerticalScrollMode(QAbstractItemView.ScrollPerPixel)
 821        self.table.cellEntered.connect(self._set_hover)
 822        self.table.viewport().installEventFilter(self)
 823        self.table.cellClicked.connect(self._row_clicked)
 824        self.table.cellDoubleClicked.connect(self._open_row)
 825        self.table.setContextMenuPolicy(Qt.CustomContextMenu)
 826        self.table.customContextMenuRequested.connect(self._context_menu)
 827        hh = self.table.horizontalHeader()
 828        hh.setHighlightSections(False)
 829        hh.setDefaultAlignment(Qt.AlignLeft | Qt.AlignVCenter)
 830        hh.setSectionResizeMode(0, QHeaderView.Stretch)
 831        for column in (1, 2, 3):
 832            hh.setSectionResizeMode(column, QHeaderView.Fixed)
 833        self.table.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
 834        self._fit_columns()
 835        self._init_sorting({
 836            0: lambda t: t.name.lower(),
 837            1: lambda t: (t.package.lower(), t.name.lower()),
 838            2: lambda t: (STATUS_SORT.get(t.status, 9), -t.warnings),
 839            3: lambda t: t.elapsed(),
 840        }, descending_first={3})
 841
 842        layout = QVBoxLayout(self)
 843        layout.setContentsMargins(14, 10, 14, 6)
 844        layout.setSpacing(8)
 845        layout.addLayout(header)
 846        layout.addLayout(progress_row)
 847        layout.addWidget(self.table, 1)
 848        # Shown instead of the table (and its column headers) while it's empty.
 849        self.empty = QLabel()
 850        self.empty.setAlignment(Qt.AlignCenter)
 851        self.empty.setWordWrap(True)
 852        layout.addWidget(self.empty, 1)
 853
 854        self._header_pending = False  # (see _header_later)
 855        # Drives the spinner, the live times and the finish flash.
 856        self._timer = QTimer(self)
 857        self._timer.setInterval(50)
 858        self._timer.timeout.connect(self._tick)
 859
 860        self.refresh_palette()
 861        self.update_header()
 862
 863    # ------------------------------------------------------------- styling
 864    def refresh_palette(self):
 865        """Follow the application's light/dark palette."""
 866        self.is_light = app_is_light()
 867        self.palette_colors = PALETTES["light" if self.is_light else "dark"]
 868        self.setStyleSheet(panel_stylesheet(self.objectName(), self.palette_colors))
 869        self.empty.setStyleSheet(f"color: {self.palette_colors['muted']};")
 870        self.title_icon.draw(self.palette_colors["text"], self.title)
 871        self._style_chips()
 872        self.update_header()
 873        self.table.viewport().update()
 874        self.progress.update()
 875
 876    def set_package_colors(self, colors: dict[str, str]):
 877        """Use the dependency graph's colors for the package tags."""
 878        self.package_colors = {**DEFAULT_PACKAGE_COLORS, **colors}
 879        self.table.viewport().update()
 880
 881    def _style_chips(self):
 882        pal = self.palette_colors
 883        for status, chip in self.chips.items():
 884            chip.setStyleSheet(chip_stylesheet(pal, pal["status"][status]))
 885
 886    # ----------------------------------------------------------- lifecycle
 887    def start(self, graph: m.DependencyGraph | None, project_dir: Path, description: str = ""):
 888        self.targets.clear()
 889        self.selected_row = -1
 890        self.table.setRowCount(0)
 891        self.sources = index_sources(graph)
 892        self.package_kinds = {n: p.kind for n, p in graph.packages.items()} if graph else {}
 893        # Dependencies fpm downloads during this build aren't on disk yet: they're
 894        # indexed when they are (see _index_new_dependencies). The graph's names
 895        # and kinds for the folders it expects them in:
 896        self._expected = {p.directory.resolve(): (n, m.GIT if p.git else m.REGISTRY)
 897                          for n, p in (graph.packages.items() if graph else ()) if p.directory}
 898        self._indexed = {p.directory.resolve() for p in (graph.packages.values() if graph else ())
 899                         if p.directory and p.directory.is_dir() and p.kind != m.MISSING}
 900        self.diagnostics = DiagnosticParser()
 901        self.project_dir = project_dir
 902        self._logs = LogFinder(project_dir)  # (fresh for each build: new folders)
 903        self.description = description
 904        self.peak_parallel = 0
 905        self._build_started = time.time()
 906        self._started = time.monotonic()
 907        self._finished_at = None
 908        self._percent = 0
 909        self._result = ""
 910        self._active = True
 911        self.set_filter(None)
 912        self.progress.set(0, "running")
 913        self._timer.start()
 914        self.update_header()
 915
 916    def reset(self):
 917        """Back to the empty state (e.g. when another project is opened)."""
 918        self._timer.stop()
 919        self.targets.clear()
 920        self.selected_row = -1
 921        self.table.setRowCount(0)
 922        self.project_dir = None
 923        self.description = ""
 924        self.peak_parallel = 0
 925        self._started = 0.0
 926        self._finished_at = None
 927        self._percent = 0
 928        self._result = ""
 929        self._active = False
 930        self.set_filter(None)
 931        self.progress.set(0, "running")
 932        self.update_header()
 933
 934    @property
 935    def percent(self) -> int:
 936        """How far the build is (0–100)."""
 937        return self._percent
 938
 939    def finish(self, exit_code: int, stopped: bool = False):
 940        if not self._active:
 941            return
 942        self._active = False
 943        self._finished_at = time.monotonic()
 944        for t in self.targets:
 945            if t.status == COMPILING:
 946                # A sibling's failure (or Stop) kills files still compiling.
 947                t.finished = self._finished_at
 948                self._set_status(t, ABORTED if (stopped or exit_code != 0) else DONE)
 949        if stopped:
 950            self._result = "stopped"
 951        elif exit_code != 0:
 952            self._result = "failed"
 953        elif self._result != "uptodate" or self.targets:
 954            self._result = "ok"
 955            self._percent = 100
 956        self.progress.set(self._percent / 100, {"uptodate": "ok"}.get(self._result, self._result))
 957        self.update_header()
 958        self.table.viewport().update()
 959
 960    # ------------------------------------------------------------- parsing
 961    def feed_line(self, line: str):
 962        """Process one complete line of fpm output."""
 963        if not self._active:
 964            return
 965        line = line.rstrip()
 966        if not line:
 967            return
 968
 969        match = PROGRESS_RE.match(line)
 970        if match:
 971            percent, name, end = int(match.group(1)), match.group(2).strip(), match.group(3)
 972            self._percent = max(self._percent, percent)
 973            self.progress.set(self._percent / 100)
 974            if end:
 975                target = self._find(name, running=True) or self._add(name)
 976                if target.status == COMPILING:
 977                    target.finished = time.monotonic()
 978                    self._read_log(target)
 979                    self._set_status(target, FAILED if end == "failed" or target.errors
 980                                     else WARNING if target.warnings else DONE)
 981            else:
 982                self._add(name)
 983            self._header_later()
 984            return
 985
 986        match = FAILED_OBJECT_RE.search(line)
 987        if match:
 988            target = self._find_object(match.group(1))
 989            if target:
 990                self._mark_failed(target)
 991            self._header_later()
 992            return
 993
 994        if UP_TO_DATE_RE.search(line):
 995            self._percent = 100
 996            self._result = "uptodate"
 997            self.progress.set(1.0, "ok")
 998            self.update_header()
 999            return
1000
1001        self._parse_diagnostic(line)
1002
1003    def _parse_diagnostic(self, line: str):
1004        found = self.diagnostics.feed(line)
1005        if found:
1006            target = self._find(Path(found.file).name, running=False)
1007            if target:
1008                self._record(target, found)
1009
1010    def _read_log(self, target: Target):
1011        """Pick up diagnostics for a finished source file from fpm's log."""
1012        if target_kind(target.name) != "Source":
1013            return
1014        if self.project_dir is None:
1015            return
1016        if self._logs is None or self._logs.project_dir != self.project_dir:
1017            self._logs = LogFinder(self.project_dir)
1018        logs = self._logs.find(target.name, [path for _package, path in target.candidates], self._build_started)
1019        if not logs:
1020            return
1021        log = max(logs)[1]
1022        self._resolve_owner(target, log.name)
1023        try:
1024            text = read_log_text(log)
1025        except OSError:
1026            return
1027        target.log_text = text  # shown with its colors
1028        parser = DiagnosticParser()
1029        for line in strip_ansi(text).splitlines():
1030            found = parser.feed(line.rstrip())
1031            if found:
1032                # Diagnostics in included files still belong to this target.
1033                self._record(target, found)
1034
1035    def _resolve_owner(self, target: Target, log_name: str):
1036        """Several packages may have a file of this name; fpm names the log
1037        after the source path relative to the project ("/" -> "_"), which
1038        tells them apart."""
1039        if len(target.candidates) < 2 or self.project_dir is None:
1040            return
1041        for package, path in target.candidates:
1042            try:
1043                rel = os.path.relpath(path, self.project_dir)
1044            except ValueError:
1045                continue
1046            if log_name == rel.replace("\\", "_").replace("/", "_") + ".o.log":
1047                self._set_owner(target, package, path)
1048                return
1049
1050    def _record(self, target: Target, diag: Diagnostic):
1051        if diag.message in target.messages:  # fpm echoes the log on failure
1052            return
1053        target.messages.append(diag.message)
1054        target.diagnostics.append(diag)
1055        severity = diag.severity
1056        if severity in ("error", "fatal error"):
1057            target.errors += 1
1058            self._mark_failed(target)
1059        elif severity == "warning":
1060            target.warnings += 1
1061            if target.status in (DONE, WARNING):
1062                self._set_status(target, WARNING)
1063        self._update_tooltip(target)
1064        self._header_later()
1065
1066    # ------------------------------------------------------------- helpers
1067    def _find(self, name: str, running: bool) -> Target | None:
1068        """Most suitable row for a file name: the oldest one still compiling,
1069        otherwise (unless ``running``) the most recent one."""
1070        for t in self.targets:
1071            if t.name == name and t.status == COMPILING:
1072                return t
1073        if running:
1074            return None
1075        for t in reversed(self.targets):
1076            if t.name == name:
1077                return t
1078        return None
1079
1080    def _find_object(self, obj: str) -> Target | None:
1081        # fpm names objects after the source path with "/" -> "_", plus ".o".
1082        stem = obj.removesuffix(".o")
1083        candidates = [t for t in self.targets if stem == t.name or stem.endswith("_" + t.name)]
1084        return max(candidates, key=lambda t: len(t.name), default=None)
1085
1086    def _mark_failed(self, target: Target):
1087        if target.finished is None:
1088            target.finished = time.monotonic()
1089        self._set_status(target, FAILED)
1090
1091    def _add(self, name: str) -> Target:
1092        row = self.table.rowCount()
1093        self.table.insertRow(row)
1094        target = Target(name, row, seq=len(self.targets))
1095        self.targets.append(target)
1096        for column in range(len(self.COLUMNS)):
1097            self.table.setItem(row, column, QTableWidgetItem())
1098
1099        kind = target_kind(name)
1100        self.table.item(row, 0).setText(name)
1101        self.table.item(row, 0).setToolTip(kind)
1102        if kind == "Source":
1103            if name not in self.sources:
1104                self._index_new_dependencies()
1105            target.candidates = self.sources.get(name, [])
1106            if len(target.candidates) == 1:
1107                self._set_owner(target, *target.candidates[0])
1108            else:
1109                names = sorted({p for p, _ in target.candidates})
1110                target.package = ", ".join(names)
1111                target.package_kind = self.package_kinds.get(names[0], LIBRARY) if names else LIBRARY
1112        else:
1113            target.package = kind.lower()
1114            target.package_kind = LIBRARY
1115        self._set_status(target, COMPILING)
1116        running = sum(1 for t in self.targets if t.status == COMPILING)
1117        self.peak_parallel = max(self.peak_parallel, running)
1118        self._apply_filter(target)
1119        if not self.table.isRowHidden(row):
1120            self.table.scrollToItem(self.table.item(row, 0))
1121        self.schedule_sort()
1122        return target
1123
1124    def _index_new_dependencies(self):
1125        """Index the dependencies downloaded since the build started (in
1126        build/dependencies; their own dependencies too, which the graph
1127        didn't know of before): each folder once."""
1128        folder = self.project_dir / "build" / "dependencies" if self.project_dir else None
1129        if folder is None or not folder.is_dir():
1130            return
1131        for directory in sorted(folder.iterdir()):
1132            here = directory.resolve()
1133            if here in self._indexed or not (directory / "fpm.toml").is_file():
1134                continue
1135            self._indexed.add(here)
1136            name, kind = self._expected.get(here, (directory.name, m.GIT if (directory / ".git").exists()
1137                                                    else m.REGISTRY))
1138            index_package(self.sources, name, directory)
1139            if self.package_kinds.get(name, m.MISSING) == m.MISSING:
1140                self.package_kinds[name] = kind  # (its color: not "not downloaded" any more)
1141
1142    def _set_owner(self, target: Target, package: str, path: Path):
1143        target.package = package
1144        target.package_kind = self.package_kinds.get(package, LIBRARY)
1145        target.path = path
1146        item = self.table.item(target.row, 0)
1147        item.setData(Qt.UserRole, str(path))
1148        item.setToolTip(str(path))
1149        self.table.item(target.row, 1).setText(package)
1150
1151    def display_folder(self, target: Target) -> str:
1152        if target.path is None:
1153            return ""
1154        folder = target.path.parent
1155        if self.project_dir is not None:
1156            try:
1157                return os.path.relpath(folder, self.project_dir)
1158            except ValueError:
1159                pass
1160        return str(folder)
1161
1162    def _set_status(self, target: Target, status: str):
1163        target.status = status
1164        self.table.item(target.row, 2).setText(status)
1165        self._apply_filter(target)
1166        self.table.viewport().update()
1167        self.schedule_sort()
1168
1169    # SortableTable hooks
1170    def rows(self) -> list[Target]:
1171        return self.targets
1172
1173    def _sync_row(self, row: int):
1174        """Rewrite a row's items (text, tooltips, data) from its target."""
1175        t = self.targets[row]
1176        file_item = self.table.item(row, 0)
1177        file_item.setText(t.name)
1178        file_item.setToolTip(str(t.path) if t.path else target_kind(t.name))
1179        file_item.setData(Qt.UserRole, str(t.path) if t.path else None)
1180        self.table.item(row, 1).setText(t.package)
1181        status_item = self.table.item(row, 2)
1182        status_item.setText(t.status)
1183        status_item.setToolTip("")
1184        self._update_tooltip(t)
1185
1186    def _reapply_filter(self):
1187        for t in self.targets:
1188            self._apply_filter(t)
1189
1190    def _update_tooltip(self, target: Target):
1191        if target.messages:
1192            text = "\n".join(target.messages[:10])
1193            if len(target.messages) > 10:
1194                text += f"\n… {len(target.messages) - 10} more"
1195            self.table.item(target.row, 2).setToolTip(text)
1196
1197    def slowest(self) -> float:
1198        return max((t.elapsed() for t in self.targets), default=0.0)
1199
1200    # ------------------------------------------------------------- display
1201    def set_filter(self, status: str | None):
1202        self.filter_status = status
1203        for s, chip in self.chips.items():
1204            chip.setChecked(s == status)
1205        for t in self.targets:
1206            self._apply_filter(t)
1207
1208    def _apply_filter(self, target: Target):
1209        hide = self.filter_status is not None and target.status != self.filter_status
1210        self.table.setRowHidden(target.row, hide)
1211
1212    def counts(self) -> dict[str, int]:
1213        counts = {s: 0 for s in STATUSES}
1214        for t in self.targets:
1215            counts[t.status] += 1
1216        return counts
1217
1218    def _header_later(self):
1219        """The header brought up to date once the lines that have come in are
1220        all read (not after each: a big build's thousands of lines each
1221        counting every row and laying out the header's text held the window)."""
1222        if not self._header_pending:
1223            self._header_pending = True
1224            QTimer.singleShot(0, self, self.update_header)
1225
1226    def update_header(self):
1227        self._header_pending = False
1228        pal = self.palette_colors
1229        parts = [self.description] if self.description else []
1230        if self.peak_parallel > 1:
1231            parts.append(f"{self.peak_parallel} parallel")
1232        self.subtitle.setText(f"<span style='color:{pal['muted']}'>{' · '.join(parts)}</span>")
1233
1234        counts = self.counts()
1235        for status, chip in self.chips.items():
1236            chip.setText(f"{STATUS_ICONS[status]} {counts[status]}")
1237            chip.setVisible(counts[status] > 0)
1238        if self.filter_status and counts[self.filter_status] == 0:
1239            self.set_filter(None)
1240
1241        end = self._finished_at or time.monotonic()
1242        seconds = f"{end - self._started:.1f} s" if self._started else ""
1243        finished = counts[DONE] + counts[WARNING]
1244        muted = f"<span style='color:{pal['muted']}'>"
1245        if self._result in ("uptodate", "ok") and not self.targets:  # nothing needed compiling
1246            text = "<b>Up to date</b>"
1247        elif self._result == "ok":
1248            text = f"<b>Finished</b> {muted}· {finished} built · {seconds}</span>"
1249        elif self._result == "failed":
1250            text = f"<b>Failed at {self._percent}%</b> {muted}· {seconds}</span>"
1251        elif self._result == "stopped":
1252            text = f"<b>Stopped at {self._percent}%</b> {muted}· {seconds}</span>"
1253        elif self._active:
1254            text = f"<b>{self._percent}%</b> {muted}· {finished} done · {seconds}</span>"
1255        else:
1256            text = f"{muted}Run Build, Run or Test to see progress</span>"
1257        self.progress_label.setText(text)
1258        self._update_empty()
1259
1260    def _update_empty(self):
1261        """While nothing has been compiled, a message instead of the table."""
1262        if self.table.rowCount():
1263            message = ""
1264        elif self._result == "uptodate" or (self._result == "ok" and not self.targets):
1265            message = "Nothing needed compiling — the project is up to date."
1266        elif self._result in ("failed", "stopped"):
1267            message = "Nothing was compiled — see the output."
1268        elif self._active:
1269            message = "Waiting for fpm…"
1270        else:
1271            message = "Run Build, Run or Test to see each file fpm compiles here."
1272        if self.empty.text() != message:
1273            self.empty.setText(message)
1274        self.empty.setVisible(bool(message))
1275        self.table.setVisible(not message)
1276
1277    def _tick(self):
1278        self.spinner_angle = (self.spinner_angle + 18) % 360
1279        now = time.monotonic()
1280        flashing = any(t.finished is not None and now - t.finished < FLASH_SECONDS for t in self.targets)
1281        self.table.viewport().update()  # also clears the last flash frame
1282        self.update_header()
1283        if not (self._active or flashing):
1284            self._timer.stop()
1285
1286    def _set_hover(self, row: int, _column: int):
1287        if row != self.hover_row:
1288            self.hover_row = row
1289            self.table.viewport().update()
1290
1291    def eventFilter(self, obj, event):
1292        if obj is self.table.viewport():
1293            if event.type() == QEvent.Leave:
1294                self._set_hover(-1, 0)
1295            elif event.type() == QEvent.Resize:
1296                self._fit_columns()
1297        return super().eventFilter(obj, event)
1298
1299    def _fit_columns(self):
1300        """Share the width out without a horizontal scrollbar; drop the
1301        package column when space is tight (the file tooltip still has it)."""
1302        width = self.table.viewport().width()
1303        narrow = width < scaled(520)
1304        self.table.setColumnHidden(1, narrow)
1305        self.table.setColumnWidth(1, scaled(130))
1306        self.table.setColumnWidth(2, scaled(140 if width >= scaled(600) else 128))
1307        self.table.setColumnWidth(3, scaled(150 if width >= scaled(700) else 112))
1308
1309    def refresh_fonts(self):
1310        """Re-apply sizes after the application font changed."""
1311        title_font = QFont(QApplication.font())
1312        title_font.setPointSizeF(title_font.pointSizeF() + 2)
1313        title_font.setWeight(QFont.Bold)
1314        self.title.setFont(title_font)
1315        self.table.verticalHeader().setDefaultSectionSize(scaled(COMPACT_ROW_HEIGHT))
1316        self.refresh_palette()  # the stylesheet has pixel sizes
1317        self._fit_columns()
1318
1319    def _row_clicked(self, row: int, _column: int):
1320        """Click a file to see its compiler messages; click again for all output."""
1321        if row >= len(self.targets):
1322            return
1323        self.selected_row = -1 if self.selected_row == row else row
1324        self.table.viewport().update()
1325        self.details_requested.emit(self.selected_row)
1326
1327    def clear_selection(self):
1328        if self.selected_row != -1:
1329            self.selected_row = -1
1330            self.table.viewport().update()
1331
1332    def details_title(self, row: int) -> str:
1333        t = self.targets[row]
1334        what = {WARNING: f"{t.warnings} warning{'s' * (t.warnings != 1)}",
1335                FAILED: "errors"}.get(t.status, t.status.lower())
1336        package = f" ({t.package})" if t.package else ""
1337        return f"{t.name}{package} — {what}"
1338
1339    def details_text(self, row: int) -> str:
1340        """The compiler output for one file, as fpm logged it."""
1341        t = self.targets[row]
1342        if t.status == COMPILING:
1343            return "(still compiling)"
1344        if t.log_text.strip():
1345            return t.log_text
1346        if t.messages:
1347            return "\n".join(t.messages)
1348        return "No compiler messages for this file."
1349
1350    def _open_row(self, row: int, _column: int):
1351        """Double-click: open the source, at its first error or warning."""
1352        path = self.table.item(row, 0).data(Qt.UserRole)
1353        if path:
1354            self.open_file_requested.emit(path, self.diagnostics_for(row))
1355
1356    def _context_menu(self, pos):
1357        row = self.table.rowAt(pos.y())
1358        if not 0 <= row < len(self.targets):
1359            return
1360        target = self.targets[row]
1361        menu = QMenu(self)
1362        if target.path is not None:
1363            path = str(target.path)
1364            menu.addAction("Show Compile Command…", lambda: self.compile_command_requested.emit(path))
1365            menu.addAction("Open in Editor",
1366                           lambda: self.open_file_requested.emit(path, self.diagnostics_for(row)))
1367        menu.addAction("Show Compiler Messages", lambda: self._row_clicked(row, 0)
1368                       if self.selected_row != row else None)
1369        if menu.actions():
1370            menu.exec(self.table.viewport().mapToGlobal(pos))
1371
1372    def diagnostics_for(self, row: int) -> list[Diagnostic]:
1373        """Diagnostics that point into the row's own source file (errors first)."""
1374        t = self.targets[row]
1375        own = [d for d in t.diagnostics if Path(d.file).name == t.name]
1376        return sorted(own, key=lambda d: (d.severity == "warning", d.line))

Per-file compile status: header with filter chips, a slim progress bar and a custom-drawn table.

BuildMonitor(parent=None)
748    def __init__(self, parent=None):
749        super().__init__(parent)
750        self.targets: list[Target] = []
751        self.sources: dict[str, list[tuple[str, Path]]] = {}
752        self.package_kinds: dict[str, str] = {}
753        self.package_colors = dict(DEFAULT_PACKAGE_COLORS)
754        self.diagnostics = DiagnosticParser()
755        self.project_dir: Path | None = None
756        self._expected: dict[Path, tuple[str, str]] = {}  # (see start and _index_new_dependencies)
757        self._indexed: set[Path] = set()
758        self._logs: LogFinder | None = None  # (where the compile logs are: see _read_log)
759        self.hover_row = -1
760        self.selected_row = -1
761        self.spinner_angle = 0.0
762        self.peak_parallel = 0
763        self.filter_status: str | None = None
764        self.description = ""
765        self._build_started = 0.0  # wall clock, to compare with log mtimes
766        self._started = 0.0        # monotonic, for the elapsed time
767        self._finished_at: float | None = None
768        self._percent = 0
769        self._result = ""          # "", "ok", "failed", "stopped", "uptodate"
770        self._active = False
771        self.is_light = True
772        self.palette_colors = PALETTES["light"]
773
774        self.setObjectName("BuildMonitor")
775        self.setAttribute(Qt.WA_StyledBackground, True)
776
777        self.title_icon = TitleIcon("build")
778        self.title = QLabel("Build")
779        title_font = self.title.font()
780        title_font.setPointSizeF(title_font.pointSizeF() + 2)
781        title_font.setWeight(QFont.Bold)
782        self.title.setFont(title_font)
783        self.subtitle = StatusLabel()
784        self.chips: dict[str, QPushButton] = {}
785        header = QHBoxLayout()
786        header.setSpacing(6)
787        header.addWidget(self.title_icon)
788        header.addWidget(self.title)
789        header.addSpacing(4)
790        header.addWidget(self.subtitle)
791        header.addStretch(1)
792        for status in STATUSES:
793            chip = QPushButton()
794            chip.setCheckable(True)
795            chip.setCursor(Qt.PointingHandCursor)
796            chip.setToolTip(f"Show only “{status}” files (click again to show all)")
797            chip.clicked.connect(lambda checked, s=status: self.set_filter(s if checked else None))
798            chip.hide()
799            header.addWidget(chip)
800            self.chips[status] = chip
801
802        self.progress = ProgressLine(self)
803        self.progress_label = StatusLabel()
804        progress_row = QHBoxLayout()
805        progress_row.setSpacing(10)
806        progress_row.addWidget(self.progress, 1)
807        progress_row.addWidget(self.progress_label)
808
809        self.table = QTableWidget(0, len(self.COLUMNS))
810        self.table.setHorizontalHeaderLabels(self.COLUMNS)
811        self.table.setItemDelegate(RowDelegate(self))
812        self.table.verticalHeader().setVisible(False)
813        self.table.verticalHeader().setDefaultSectionSize(scaled(COMPACT_ROW_HEIGHT))
814        self.table.setShowGrid(False)
815        self.table.setFrameShape(QTableWidget.NoFrame)
816        self.table.setEditTriggers(QAbstractItemView.NoEditTriggers)
817        self.table.setSelectionMode(QAbstractItemView.NoSelection)
818        self.table.setFocusPolicy(Qt.NoFocus)
819        self.table.setMouseTracking(True)
820        self.table.setVerticalScrollMode(QAbstractItemView.ScrollPerPixel)
821        self.table.cellEntered.connect(self._set_hover)
822        self.table.viewport().installEventFilter(self)
823        self.table.cellClicked.connect(self._row_clicked)
824        self.table.cellDoubleClicked.connect(self._open_row)
825        self.table.setContextMenuPolicy(Qt.CustomContextMenu)
826        self.table.customContextMenuRequested.connect(self._context_menu)
827        hh = self.table.horizontalHeader()
828        hh.setHighlightSections(False)
829        hh.setDefaultAlignment(Qt.AlignLeft | Qt.AlignVCenter)
830        hh.setSectionResizeMode(0, QHeaderView.Stretch)
831        for column in (1, 2, 3):
832            hh.setSectionResizeMode(column, QHeaderView.Fixed)
833        self.table.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
834        self._fit_columns()
835        self._init_sorting({
836            0: lambda t: t.name.lower(),
837            1: lambda t: (t.package.lower(), t.name.lower()),
838            2: lambda t: (STATUS_SORT.get(t.status, 9), -t.warnings),
839            3: lambda t: t.elapsed(),
840        }, descending_first={3})
841
842        layout = QVBoxLayout(self)
843        layout.setContentsMargins(14, 10, 14, 6)
844        layout.setSpacing(8)
845        layout.addLayout(header)
846        layout.addLayout(progress_row)
847        layout.addWidget(self.table, 1)
848        # Shown instead of the table (and its column headers) while it's empty.
849        self.empty = QLabel()
850        self.empty.setAlignment(Qt.AlignCenter)
851        self.empty.setWordWrap(True)
852        layout.addWidget(self.empty, 1)
853
854        self._header_pending = False  # (see _header_later)
855        # Drives the spinner, the live times and the finish flash.
856        self._timer = QTimer(self)
857        self._timer.setInterval(50)
858        self._timer.timeout.connect(self._tick)
859
860        self.refresh_palette()
861        self.update_header()
COLUMNS: ClassVar[list[str]] = ['FILE', 'PACKAGE', 'STATUS', 'TIME']
def details_requested(unknown):

The type of the None singleton.

def open_file_requested(unknown):

The type of the None singleton.

def compile_command_requested(unknown):

The type of the None singleton.

targets: list[Target]
sources: dict[str, list[tuple[str, pathlib.Path]]]
package_kinds: dict[str, str]
package_colors
diagnostics
project_dir: pathlib.Path | None
hover_row
selected_row
spinner_angle
peak_parallel
filter_status: str | None
description
is_light
palette_colors
title_icon
title
subtitle
chips: dict[str, PySide6.QtWidgets.QPushButton]
progress
progress_label
table
empty
def refresh_palette(self):
864    def refresh_palette(self):
865        """Follow the application's light/dark palette."""
866        self.is_light = app_is_light()
867        self.palette_colors = PALETTES["light" if self.is_light else "dark"]
868        self.setStyleSheet(panel_stylesheet(self.objectName(), self.palette_colors))
869        self.empty.setStyleSheet(f"color: {self.palette_colors['muted']};")
870        self.title_icon.draw(self.palette_colors["text"], self.title)
871        self._style_chips()
872        self.update_header()
873        self.table.viewport().update()
874        self.progress.update()

Follow the application's light/dark palette.

def set_package_colors(self, colors: dict[str, str]):
876    def set_package_colors(self, colors: dict[str, str]):
877        """Use the dependency graph's colors for the package tags."""
878        self.package_colors = {**DEFAULT_PACKAGE_COLORS, **colors}
879        self.table.viewport().update()

Use the dependency graph's colors for the package tags.

def start( self, graph: vibefpmgui.manifest.DependencyGraph | None, project_dir: pathlib.Path, description: str = ''):
887    def start(self, graph: m.DependencyGraph | None, project_dir: Path, description: str = ""):
888        self.targets.clear()
889        self.selected_row = -1
890        self.table.setRowCount(0)
891        self.sources = index_sources(graph)
892        self.package_kinds = {n: p.kind for n, p in graph.packages.items()} if graph else {}
893        # Dependencies fpm downloads during this build aren't on disk yet: they're
894        # indexed when they are (see _index_new_dependencies). The graph's names
895        # and kinds for the folders it expects them in:
896        self._expected = {p.directory.resolve(): (n, m.GIT if p.git else m.REGISTRY)
897                          for n, p in (graph.packages.items() if graph else ()) if p.directory}
898        self._indexed = {p.directory.resolve() for p in (graph.packages.values() if graph else ())
899                         if p.directory and p.directory.is_dir() and p.kind != m.MISSING}
900        self.diagnostics = DiagnosticParser()
901        self.project_dir = project_dir
902        self._logs = LogFinder(project_dir)  # (fresh for each build: new folders)
903        self.description = description
904        self.peak_parallel = 0
905        self._build_started = time.time()
906        self._started = time.monotonic()
907        self._finished_at = None
908        self._percent = 0
909        self._result = ""
910        self._active = True
911        self.set_filter(None)
912        self.progress.set(0, "running")
913        self._timer.start()
914        self.update_header()
def reset(self):
916    def reset(self):
917        """Back to the empty state (e.g. when another project is opened)."""
918        self._timer.stop()
919        self.targets.clear()
920        self.selected_row = -1
921        self.table.setRowCount(0)
922        self.project_dir = None
923        self.description = ""
924        self.peak_parallel = 0
925        self._started = 0.0
926        self._finished_at = None
927        self._percent = 0
928        self._result = ""
929        self._active = False
930        self.set_filter(None)
931        self.progress.set(0, "running")
932        self.update_header()

Back to the empty state (e.g. when another project is opened).

percent: int
934    @property
935    def percent(self) -> int:
936        """How far the build is (0–100)."""
937        return self._percent

How far the build is (0–100).

def finish(self, exit_code: int, stopped: bool = False):
939    def finish(self, exit_code: int, stopped: bool = False):
940        if not self._active:
941            return
942        self._active = False
943        self._finished_at = time.monotonic()
944        for t in self.targets:
945            if t.status == COMPILING:
946                # A sibling's failure (or Stop) kills files still compiling.
947                t.finished = self._finished_at
948                self._set_status(t, ABORTED if (stopped or exit_code != 0) else DONE)
949        if stopped:
950            self._result = "stopped"
951        elif exit_code != 0:
952            self._result = "failed"
953        elif self._result != "uptodate" or self.targets:
954            self._result = "ok"
955            self._percent = 100
956        self.progress.set(self._percent / 100, {"uptodate": "ok"}.get(self._result, self._result))
957        self.update_header()
958        self.table.viewport().update()
def feed_line(self, line: str):
 961    def feed_line(self, line: str):
 962        """Process one complete line of fpm output."""
 963        if not self._active:
 964            return
 965        line = line.rstrip()
 966        if not line:
 967            return
 968
 969        match = PROGRESS_RE.match(line)
 970        if match:
 971            percent, name, end = int(match.group(1)), match.group(2).strip(), match.group(3)
 972            self._percent = max(self._percent, percent)
 973            self.progress.set(self._percent / 100)
 974            if end:
 975                target = self._find(name, running=True) or self._add(name)
 976                if target.status == COMPILING:
 977                    target.finished = time.monotonic()
 978                    self._read_log(target)
 979                    self._set_status(target, FAILED if end == "failed" or target.errors
 980                                     else WARNING if target.warnings else DONE)
 981            else:
 982                self._add(name)
 983            self._header_later()
 984            return
 985
 986        match = FAILED_OBJECT_RE.search(line)
 987        if match:
 988            target = self._find_object(match.group(1))
 989            if target:
 990                self._mark_failed(target)
 991            self._header_later()
 992            return
 993
 994        if UP_TO_DATE_RE.search(line):
 995            self._percent = 100
 996            self._result = "uptodate"
 997            self.progress.set(1.0, "ok")
 998            self.update_header()
 999            return
1000
1001        self._parse_diagnostic(line)

Process one complete line of fpm output.

def display_folder(self, target: Target) -> str:
1151    def display_folder(self, target: Target) -> str:
1152        if target.path is None:
1153            return ""
1154        folder = target.path.parent
1155        if self.project_dir is not None:
1156            try:
1157                return os.path.relpath(folder, self.project_dir)
1158            except ValueError:
1159                pass
1160        return str(folder)
def rows(self) -> list[Target]:
1170    def rows(self) -> list[Target]:
1171        return self.targets
def slowest(self) -> float:
1197    def slowest(self) -> float:
1198        return max((t.elapsed() for t in self.targets), default=0.0)
def set_filter(self, status: str | None):
1201    def set_filter(self, status: str | None):
1202        self.filter_status = status
1203        for s, chip in self.chips.items():
1204            chip.setChecked(s == status)
1205        for t in self.targets:
1206            self._apply_filter(t)
def counts(self) -> dict[str, int]:
1212    def counts(self) -> dict[str, int]:
1213        counts = {s: 0 for s in STATUSES}
1214        for t in self.targets:
1215            counts[t.status] += 1
1216        return counts
def update_header(self):
1226    def update_header(self):
1227        self._header_pending = False
1228        pal = self.palette_colors
1229        parts = [self.description] if self.description else []
1230        if self.peak_parallel > 1:
1231            parts.append(f"{self.peak_parallel} parallel")
1232        self.subtitle.setText(f"<span style='color:{pal['muted']}'>{' · '.join(parts)}</span>")
1233
1234        counts = self.counts()
1235        for status, chip in self.chips.items():
1236            chip.setText(f"{STATUS_ICONS[status]} {counts[status]}")
1237            chip.setVisible(counts[status] > 0)
1238        if self.filter_status and counts[self.filter_status] == 0:
1239            self.set_filter(None)
1240
1241        end = self._finished_at or time.monotonic()
1242        seconds = f"{end - self._started:.1f} s" if self._started else ""
1243        finished = counts[DONE] + counts[WARNING]
1244        muted = f"<span style='color:{pal['muted']}'>"
1245        if self._result in ("uptodate", "ok") and not self.targets:  # nothing needed compiling
1246            text = "<b>Up to date</b>"
1247        elif self._result == "ok":
1248            text = f"<b>Finished</b> {muted}· {finished} built · {seconds}</span>"
1249        elif self._result == "failed":
1250            text = f"<b>Failed at {self._percent}%</b> {muted}· {seconds}</span>"
1251        elif self._result == "stopped":
1252            text = f"<b>Stopped at {self._percent}%</b> {muted}· {seconds}</span>"
1253        elif self._active:
1254            text = f"<b>{self._percent}%</b> {muted}· {finished} done · {seconds}</span>"
1255        else:
1256            text = f"{muted}Run Build, Run or Test to see progress</span>"
1257        self.progress_label.setText(text)
1258        self._update_empty()
def eventFilter(self, obj, event):
1291    def eventFilter(self, obj, event):
1292        if obj is self.table.viewport():
1293            if event.type() == QEvent.Leave:
1294                self._set_hover(-1, 0)
1295            elif event.type() == QEvent.Resize:
1296                self._fit_columns()
1297        return super().eventFilter(obj, event)

eventFilter(self, watched: PySide6.QtCore.QObject, event: PySide6.QtCore.QEvent, /) -> bool

def refresh_fonts(self):
1309    def refresh_fonts(self):
1310        """Re-apply sizes after the application font changed."""
1311        title_font = QFont(QApplication.font())
1312        title_font.setPointSizeF(title_font.pointSizeF() + 2)
1313        title_font.setWeight(QFont.Bold)
1314        self.title.setFont(title_font)
1315        self.table.verticalHeader().setDefaultSectionSize(scaled(COMPACT_ROW_HEIGHT))
1316        self.refresh_palette()  # the stylesheet has pixel sizes
1317        self._fit_columns()

Re-apply sizes after the application font changed.

def clear_selection(self):
1327    def clear_selection(self):
1328        if self.selected_row != -1:
1329            self.selected_row = -1
1330            self.table.viewport().update()
def details_title(self, row: int) -> str:
1332    def details_title(self, row: int) -> str:
1333        t = self.targets[row]
1334        what = {WARNING: f"{t.warnings} warning{'s' * (t.warnings != 1)}",
1335                FAILED: "errors"}.get(t.status, t.status.lower())
1336        package = f" ({t.package})" if t.package else ""
1337        return f"{t.name}{package} — {what}"
def details_text(self, row: int) -> str:
1339    def details_text(self, row: int) -> str:
1340        """The compiler output for one file, as fpm logged it."""
1341        t = self.targets[row]
1342        if t.status == COMPILING:
1343            return "(still compiling)"
1344        if t.log_text.strip():
1345            return t.log_text
1346        if t.messages:
1347            return "\n".join(t.messages)
1348        return "No compiler messages for this file."

The compiler output for one file, as fpm logged it.

def diagnostics_for(self, row: int) -> list[Diagnostic]:
1372    def diagnostics_for(self, row: int) -> list[Diagnostic]:
1373        """Diagnostics that point into the row's own source file (errors first)."""
1374        t = self.targets[row]
1375        own = [d for d in t.diagnostics if Path(d.file).name == t.name]
1376        return sorted(own, key=lambda d: (d.severity == "warning", d.line))

Diagnostics that point into the row's own source file (errors first).

staticMetaObject = PySide6.QtCore.QMetaObject("BuildMonitor" inherits "QWidget": Methods: #33 type=Signal, signature=details_requested(int), parameters=int #34 type=Signal, signature=open_file_requested(QString,QVariantList), parameters=QString, QVariantList #35 type=Signal, signature=compile_command_requested(QString), parameters=QString )