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

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: 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 

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: 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")) 

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: 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 

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(): 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)) 

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): 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]) 

344 

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()) 

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: 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) 

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: 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)") 

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(): 485 ↛ 486line 485 didn't jump to line 486 because the condition on line 485 was never true

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: 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() 

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: 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() 

958 

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 

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: 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 

984 

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 

992 

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 

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: 1006 ↛ exitline 1006 didn't return from function '_parse_diagnostic' because the condition on line 1006 was always true

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: 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) 

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: 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 

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"): 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() 

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: 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 

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: 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) 

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): 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 

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(): 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) 

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: 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) 

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: 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) 

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: 1204 ↛ 1205line 1204 didn't jump to line 1205 because the loop on line 1204 never started

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: 1237 ↛ 1238line 1237 didn't jump to line 1238 because the condition on line 1237 was never true

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 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() 

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): 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) 

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(): 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) 

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: 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() 

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: 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." 

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))