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))
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.
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)
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.
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
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.
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
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.
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).
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.
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.
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.
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.
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.
sizeHint(self, option: PySide6.QtWidgets.QStyleOptionViewItem, index: PySide6.QtCore.QModelIndex | PySide6.QtCore.QPersistentModelIndex, /) -> PySide6.QtCore.QSize
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
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.
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.
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()
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.
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.
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.
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.
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
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.
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)
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.
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.
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).
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.
__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.
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.
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()
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.
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.
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()
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).
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).
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()
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.
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()
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
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.
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.
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).