Coverage for vibefpmgui/build_monitor.py: 81%
996 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-11-01 20:51 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-11-01 20:51 +0000
1"""Live table of the files fpm is compiling, fed from fpm's console output.
3When its output is not a terminal, fpm prints one line when it starts a
4target and another when it finishes, e.g.::
6 [ 11%] json_parameters.F90
7 [ 22%] json_parameters.F90 done.
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.
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.
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"""
24from __future__ import annotations
26import os
27import re
28import time
29from dataclasses import dataclass, field
30from pathlib import Path
31from typing import ClassVar, NamedTuple
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)
59from . import manifest as m
60from .ansi import strip as strip_ansi
61from .icons import make_icon
62from .widgets import StatusLabel
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")
80SOURCE_EXTS = {".f", ".for", ".ftn", ".f77", ".f90", ".f95", ".f03", ".f08", ".fpp",
81 ".c", ".cc", ".cpp", ".cxx", ".h", ".inc"}
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
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"}
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
106# Text-size factor set by the main window's View menu (1.0 = normal).
107_ui_scale = 1.0
110def set_ui_scale(factor: float):
111 global _ui_scale
112 _ui_scale = factor
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
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
128class DiagnosticParser:
129 """Turns compiler output, one line at a time, into `Diagnostic`."""
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
135 def feed(self, line: str) -> Diagnostic | None:
136 match = LFORTRAN_SEVERITY_RE.match(line)
137 if match: 137 ↛ 138line 137 didn't jump to line 138 because the condition on line 137 was never true
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: 141 ↛ 142line 141 didn't jump to line 142 because the condition on line 141 was never true
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: 148 ↛ 149line 148 didn't jump to line 149 because the condition on line 148 was never true
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) 152 ↛ 153line 152 didn't jump to line 153 because the condition on line 152 was never true
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
163LOG_READ_LIMIT = 1_000_000 # bytes of a compiler log read (half from each end)
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: 171 ↛ 173line 171 didn't jump to line 173 because the condition on line 171 was always true
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"))
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"
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
213 def elapsed(self) -> float:
214 return (self.finished or time.monotonic()) - self.started
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: 221 ↛ 222line 221 didn't jump to line 222 because the condition on line 221 was never true
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
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))
245# ----------------------------------------------------------------- drawing
246def _tint(color: QColor, alpha: int) -> QColor:
247 c = QColor(color)
248 c.setAlpha(alpha)
249 return c
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 """
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 """
292def app_is_light() -> bool:
293 return QApplication.palette().color(QPalette.Base).lightness() >= 128
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(): 306 ↛ 307line 306 didn't jump to line 307 because the condition on line 306 was never true
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: 316 ↛ 317line 316 didn't jump to line 317 because the condition on line 316 was never true
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))
324class RowDelegate(QStyledItemDelegate):
325 """Paints every cell of the build table from its `Target`."""
327 def __init__(self, monitor: BuildMonitor):
328 super().__init__(monitor.table)
329 self.monitor = monitor
331 def sizeHint(self, option, index):
332 return QSize(100, scaled(COMPACT_ROW_HEIGHT))
334 def paint(self, p: QPainter, option, index):
335 mon = self.monitor
336 if index.row() >= len(mon.targets): 336 ↛ 337line 336 didn't jump to line 337 because the condition on line 336 was never true
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])
345 if index.row() == mon.selected_row: 345 ↛ 346line 345 didn't jump to line 346 because the condition on line 345 was never true
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: 349 ↛ 350line 349 didn't jump to line 350 because the condition on line 349 was never true
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())
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()
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)
377 def _paint_package(self, p, r, target, small, pal):
378 if not target.package: 378 ↛ 379line 378 didn't jump to line 379 because the condition on line 378 was never true
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)
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)
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")
436class SortableTable:
437 """Click-to-sort for the custom-drawn status tables.
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).
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 """
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: 465 ↛ 463line 465 didn't jump to line 463 because the condition on line 465 was always true
466 item.setToolTip("Click to sort (again to reverse, a third time for the original order)")
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()
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(): 485 ↛ 486line 485 didn't jump to line 486 because the condition on line 485 was never true
486 self._sort_timer.start()
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()
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)
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
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
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."""
526 SEGMENT = 0.28 # busy segment width, as a fraction of the bar
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)
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()
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: 555 ↛ 556line 555 didn't jump to line 556 because the condition on line 555 was never true
556 self._stop_busy()
557 self.update()
559 def _stop_busy(self):
560 self.busy = False
561 self._busy_timer.stop()
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)
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)
611# ------------------------------------------------------------------ widget
612STATUS_SORT = {FAILED: 0, WARNING: 1, COMPILING: 2, ABORTED: 3, DONE: 4}
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.
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."""
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)
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]
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
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
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 []
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]
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]
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."""
725 def __init__(self, name: str, parent=None):
726 super().__init__(parent)
727 self.name = name
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)
735class BuildMonitor(SortableTable, QWidget):
736 """Per-file compile status: header with filter chips, a slim progress bar
737 and a custom-drawn table."""
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)
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"]
773 self.setObjectName("BuildMonitor")
774 self.setAttribute(Qt.WA_StyledBackground, True)
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
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)
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})
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)
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)
859 self.refresh_palette()
860 self.update_header()
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()
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()
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]))
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()
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()
933 @property
934 def percent(self) -> int:
935 """How far the build is (0–100)."""
936 return self._percent
938 def finish(self, exit_code: int, stopped: bool = False):
939 if not self._active: 939 ↛ 940line 939 didn't jump to line 940 because the condition on line 939 was never true
940 return
941 self._active = False
942 self._finished_at = time.monotonic()
943 for t in self.targets:
944 if t.status == COMPILING: 944 ↛ 946line 944 didn't jump to line 946 because the condition on line 944 was never true
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: 952 ↛ 955line 952 didn't jump to line 955 because the condition on line 952 was always true
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()
959 # ------------------------------------------------------------- parsing
960 def feed_line(self, line: str):
961 """Process one complete line of fpm output."""
962 if not self._active: 962 ↛ 963line 962 didn't jump to line 963 because the condition on line 962 was never true
963 return
964 line = line.rstrip()
965 if not line:
966 return
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: 975 ↛ 982line 975 didn't jump to line 982 because the condition on line 975 was always true
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
985 match = FAILED_OBJECT_RE.search(line)
986 if match:
987 target = self._find_object(match.group(1))
988 if target: 988 ↛ 990line 988 didn't jump to line 990 because the condition on line 988 was always true
989 self._mark_failed(target)
990 self._header_later()
991 return
993 if UP_TO_DATE_RE.search(line): 993 ↛ 994line 993 didn't jump to line 994 because the condition on line 993 was never true
994 self._percent = 100
995 self._result = "uptodate"
996 self.progress.set(1.0, "ok")
997 self.update_header()
998 return
1000 self._parse_diagnostic(line)
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: 1006 ↛ exitline 1006 didn't return from function '_parse_diagnostic' because the condition on line 1006 was always true
1007 self._record(target, found)
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: 1013 ↛ 1014line 1013 didn't jump to line 1014 because the condition on line 1013 was never true
1014 return
1015 if self._logs is None or self._logs.project_dir != self.project_dir: 1015 ↛ 1016line 1015 didn't jump to line 1016 because the condition on line 1015 was never true
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)
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: 1038 ↛ 1040line 1038 didn't jump to line 1040 because the condition on line 1038 was always true
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
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"): 1055 ↛ 1058line 1055 didn't jump to line 1058 because the condition on line 1055 was always true
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()
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: 1072 ↛ 1073line 1072 didn't jump to line 1073 because the condition on line 1072 was never true
1073 return None
1074 for t in reversed(self.targets): 1074 ↛ 1077line 1074 didn't jump to line 1077 because the loop on line 1074 didn't complete
1075 if t.name == name: 1075 ↛ 1074line 1075 didn't jump to line 1074 because the condition on line 1075 was always true
1076 return t
1077 return None
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)
1085 def _mark_failed(self, target: Target):
1086 if target.finished is None: 1086 ↛ 1087line 1086 didn't jump to line 1087 because the condition on line 1086 was never true
1087 target.finished = time.monotonic()
1088 self._set_status(target, FAILED)
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())
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): 1118 ↛ 1120line 1118 didn't jump to line 1120 because the condition on line 1118 was always true
1119 self.table.scrollToItem(self.table.item(row, 0))
1120 self.schedule_sort()
1121 return target
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(): 1128 ↛ 1129line 1128 didn't jump to line 1129 because the condition on line 1128 was never true
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: 1138 ↛ 1130line 1138 didn't jump to line 1130 because the condition on line 1138 was always true
1139 self.package_kinds[name] = kind # (its color: not "not downloaded" any more)
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)
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: 1154 ↛ 1159line 1154 didn't jump to line 1159 because the condition on line 1154 was always true
1155 try:
1156 return os.path.relpath(folder, self.project_dir)
1157 except ValueError:
1158 pass
1159 return str(folder)
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()
1168 # SortableTable hooks
1169 def rows(self) -> list[Target]:
1170 return self.targets
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)
1185 def _reapply_filter(self):
1186 for t in self.targets:
1187 self._apply_filter(t)
1189 def _update_tooltip(self, target: Target):
1190 if target.messages: 1190 ↛ exitline 1190 didn't return from function '_update_tooltip' because the condition on line 1190 was always true
1191 text = "\n".join(target.messages[:10])
1192 if len(target.messages) > 10: 1192 ↛ 1193line 1192 didn't jump to line 1193 because the condition on line 1192 was never true
1193 text += f"\n… {len(target.messages) - 10} more"
1194 self.table.item(target.row, 2).setToolTip(text)
1196 def slowest(self) -> float:
1197 return max((t.elapsed() for t in self.targets), default=0.0)
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: 1204 ↛ 1205line 1204 didn't jump to line 1205 because the loop on line 1204 never started
1205 self._apply_filter(t)
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)
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
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)
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>")
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: 1237 ↛ 1238line 1237 didn't jump to line 1238 because the condition on line 1237 was never true
1238 self.set_filter(None)
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 1244 ↛ 1245line 1244 didn't jump to line 1245 because the condition on line 1244 was never true
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()
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): 1263 ↛ 1264line 1263 didn't jump to line 1264 because the condition on line 1263 was never true
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)
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()
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()
1290 def eventFilter(self, obj, event):
1291 if obj is self.table.viewport(): 1291 ↛ 1296line 1291 didn't jump to line 1296 because the condition on line 1291 was always true
1292 if event.type() == QEvent.Leave: 1292 ↛ 1293line 1292 didn't jump to line 1293 because the condition on line 1292 was never true
1293 self._set_hover(-1, 0)
1294 elif event.type() == QEvent.Resize:
1295 self._fit_columns()
1296 return super().eventFilter(obj, event)
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))
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()
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)
1326 def clear_selection(self):
1327 if self.selected_row != -1: 1327 ↛ 1328line 1327 didn't jump to line 1328 because the condition on line 1327 was never true
1328 self.selected_row = -1
1329 self.table.viewport().update()
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}"
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: 1341 ↛ 1342line 1341 didn't jump to line 1342 because the condition on line 1341 was never true
1342 return "(still compiling)"
1343 if t.log_text.strip(): 1343 ↛ 1345line 1343 didn't jump to line 1345 because the condition on line 1343 was always true
1344 return t.log_text
1345 if t.messages:
1346 return "\n".join(t.messages)
1347 return "No compiler messages for this file."
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))
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))
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))