Coverage for vibefpmgui/build_outputs.py: 90%

453 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-11-01 20:51 +0000

1"""What fpm has left in a project's ``build/`` folder, and a tab to show it. 

2 

3fpm splits one build over several folders named ``<compiler>_<hash>``, where 

4the hash covers the settings that affect that step: one folder for compiled 

5files and modules, one for the library archive, one for linked programs. A 

6build with other settings (profile, flags, features) adds another set and old 

7sets are never removed. 

8 

9The hashes can't be turned back into settings, so the GUI records, for every 

10build it runs, which folders that build created or changed together with the 

11settings used (`ConfigRecords`). Folders it has no record for (built 

12before this feature, or by fpm on the command line) are grouped by when they 

13were last written. 

14 

15``build/compile_commands.json`` (the exact compiler command for each source 

16file of the most recent build) is read by `read_compile_commands`. 

17""" 

18 

19from __future__ import annotations 

20 

21import json 

22import os 

23import re 

24import shlex 

25import shutil 

26import time 

27from dataclasses import dataclass, field 

28from pathlib import Path 

29 

30from qtpy.QtCore import QSize, Qt, Signal 

31from qtpy.QtGui import QFont 

32from qtpy.QtWidgets import ( 

33 QApplication, 

34 QFrame, 

35 QHBoxLayout, 

36 QLabel, 

37 QMenu, 

38 QScrollArea, 

39 QSizePolicy, 

40 QToolButton, 

41 QVBoxLayout, 

42 QWidget, 

43) 

44 

45from .build_monitor import PALETTES, app_is_light 

46from .compat import WINDOWS, quote_arg, reveal_label 

47from .icons import make_icon 

48 

49BUILD_DIR_RE = re.compile(r"^(?P<compiler>.+)_(?P<hash>[0-9A-F]{16})$") 

50PROGRAM_KINDS = {"app": "program", "test": "test", "example": "example"} 

51GROUP_GAP_SECONDS = 120 # unrecorded folders written this close together form one build 

52 

53 

54@dataclass 

55class BuildFolder: 

56 path: Path 

57 compiler: str 

58 newest: float = 0.0 # newest file modification time 

59 size: int = 0 # bytes 

60 programs: list[tuple[str, Path]] = field(default_factory=list) # (kind, path) 

61 libraries: list[Path] = field(default_factory=list) 

62 modules: int = 0 

63 objects: int = 0 

64 

65 @property 

66 def name(self) -> str: 

67 return self.path.name 

68 

69 @property 

70 def role(self) -> str: 

71 if self.programs: 

72 return "programs" 

73 if self.objects or self.modules: 

74 return "compiled files" 

75 if self.libraries: 

76 return "library" 

77 return "other" 

78 

79 

80@dataclass 

81class Configuration: 

82 label: str # e.g. "release · gfortran" 

83 folders: list[BuildFolder] 

84 recorded: bool # built by the GUI with known settings 

85 flags: str = "" 

86 built: float = 0.0 

87 used: float = 0.0 # last built or found up to date by the GUI 

88 current: bool = False 

89 

90 @property 

91 def size(self) -> int: 

92 return sum(f.size for f in self.folders) 

93 

94 @property 

95 def programs(self) -> list[tuple[str, Path]]: 

96 return sorted((p for f in self.folders for p in f.programs), 

97 key=lambda kp: (list(PROGRAM_KINDS.values()).index(kp[0]), kp[1].name)) 

98 

99 @property 

100 def libraries(self) -> list[Path]: 

101 return [lib for f in self.folders for lib in f.libraries] 

102 

103 @property 

104 def modules(self) -> int: 

105 return sum(f.modules for f in self.folders) 

106 

107 @property 

108 def objects(self) -> int: 

109 return sum(f.objects for f in self.folders) 

110 

111 

112# ----------------------------------------------------------------- scanning 

113def build_folders(project_dir: Path) -> list[Path]: 

114 build = project_dir / "build" 

115 if not build.is_dir(): 

116 return [] 

117 return sorted(p for p in build.iterdir() if p.is_dir() and BUILD_DIR_RE.match(p.name)) 

118 

119 

120def _is_program(path: Path) -> bool: 

121 if os.name == "nt": 121 ↛ 122line 121 didn't jump to line 122 because the condition on line 121 was never true

122 return path.suffix.lower() == ".exe" 

123 return os.access(path, os.X_OK) and not path.suffix 

124 

125 

126def scan_folder(path: Path) -> BuildFolder: 

127 match = BUILD_DIR_RE.match(path.name) 

128 folder = BuildFolder(path, match.group("compiler") if match else "?") 

129 for dirpath, _dirs, files in os.walk(path): 

130 rel_top = Path(dirpath).relative_to(path).parts[:1] 

131 for name in files: 

132 file = Path(dirpath) / name 

133 try: 

134 stat = file.stat() 

135 except OSError: 

136 continue 

137 folder.size += stat.st_size 

138 folder.newest = max(folder.newest, stat.st_mtime) 

139 suffix = file.suffix.lower() 

140 if suffix in (".mod", ".smod"): 

141 folder.modules += 1 

142 elif suffix in (".o", ".obj"): 

143 folder.objects += 1 

144 elif suffix in (".a", ".lib") or (suffix in (".so", ".dylib", ".dll") and name.startswith("lib")): 

145 folder.libraries.append(file) 

146 elif rel_top and rel_top[0] in PROGRAM_KINDS and _is_program(file): 

147 folder.programs.append((PROGRAM_KINDS[rel_top[0]], file)) 

148 return folder 

149 

150 

151def snapshot(project_dir: Path) -> dict[str, float]: 

152 """Folder name -> newest file time, to see what a build changed.""" 

153 return {p.name: scan_folder(p).newest for p in build_folders(project_dir)} 

154 

155 

156def changed_folders(before: dict[str, float], after: dict[str, float]) -> list[str]: 

157 return sorted(name for name, newest in after.items() if newest > before.get(name, -1)) 

158 

159 

160# --------------------------------------------------------- compile commands 

161def read_compile_commands(project_dir: Path) -> tuple[dict[str, list[str]], str | None]: 

162 """{resolved source path: argument list} from build/compile_commands.json, 

163 plus the name of the build folder the commands compile into.""" 

164 path = project_dir / "build" / "compile_commands.json" 

165 try: 

166 entries = json.loads(path.read_text()) 

167 except (OSError, ValueError): 

168 return {}, None 

169 commands: dict[str, list[str]] = {} 

170 folder = None 

171 for entry in entries if isinstance(entries, list) else []: 

172 if not isinstance(entry, dict) or "file" not in entry: 172 ↛ 173line 172 didn't jump to line 173 because the condition on line 172 was never true

173 continue 

174 args = entry.get("arguments") or shlex.split(entry.get("command", "")) 

175 base = Path(entry.get("directory") or project_dir) 

176 source = (base / entry["file"]).resolve() 

177 commands[str(source)] = list(args) 

178 if folder is None and "-J" in args: 

179 j = args.index("-J") 

180 if j + 1 < len(args): 180 ↛ 171line 180 didn't jump to line 171 because the condition on line 180 was always true

181 folder = Path(args[j + 1]).name 

182 return commands, folder 

183 

184 

185def format_command(args: list[str]) -> str: 

186 """A shell-quoted command, one option per line for readability.""" 

187 if not args: 187 ↛ 188line 187 didn't jump to line 188 because the condition on line 187 was never true

188 return "" 

189 lines = [quote_arg(args[0])] 

190 i = 1 

191 while i < len(args): 

192 part = quote_arg(args[i]) 

193 # Keep an option and its value together ("-o file", "-J dir"). 

194 if args[i] in ("-o", "-J", "-I", "-c") and i + 1 < len(args): 

195 part += " " + quote_arg(args[i + 1]) 

196 i += 1 

197 lines.append(part) 

198 i += 1 

199 # Line continuation: "\" in POSIX shells, "^" in the Windows shell. 

200 return f" {'^' if WINDOWS else chr(92)}\n ".join(lines) 

201 

202 

203# ------------------------------------------------------------------ records 

204class ConfigRecords: 

205 """Per-project record of which folders each GUI build wrote, stored in 

206 the app's settings as JSON: [{label, flags, folders, built}, ...].""" 

207 

208 def __init__(self, settings, key: str): 

209 self.settings, self.key = settings, key 

210 

211 def load(self) -> list[dict]: 

212 try: 

213 data = json.loads(self.settings.value(self.key, "[]")) 

214 return [r for r in data if isinstance(r, dict) and r.get("folders")] 

215 except (TypeError, ValueError): 

216 return [] 

217 

218 def save(self, records: list[dict]): 

219 self.settings.setValue(self.key, json.dumps(records)) 

220 

221 def last_used(self) -> tuple[str, str] | None: 

222 """(label, flags) of the last build the GUI ran, if any.""" 

223 try: 

224 data = json.loads(self.settings.value(self.key + "-last", "null")) 

225 return (data["label"], data["flags"]) if data else None 

226 except (TypeError, ValueError, KeyError): 

227 return None 

228 

229 def _set_last(self, label: str, flags: str): 

230 self.settings.setValue(self.key + "-last", json.dumps({"label": label, "flags": flags})) 

231 

232 def record_build(self, label: str, flags: str, folders: list[str]): 

233 """``folders`` were just written by a build with these settings.""" 

234 self._set_last(label, flags) 

235 if not folders: 235 ↛ 236line 235 didn't jump to line 236 because the condition on line 235 was never true

236 return 

237 records = self.load() 

238 for r in records: # a folder belongs to one configuration only 

239 r["folders"] = [f for f in r["folders"] if f not in folders] 

240 existing = next((r for r in records if r.get("label") == label and r.get("flags") == flags), None) 

241 if existing is None: 

242 existing = {"label": label, "flags": flags, "folders": []} 

243 records.append(existing) 

244 existing["folders"] = sorted(set(existing["folders"]) | set(folders)) 

245 existing["built"] = existing["used"] = time.time() 

246 self.save([r for r in records if r["folders"]]) 

247 

248 def mark_used(self, label: str, flags: str): 

249 """A build with these settings found nothing to do: it's still current.""" 

250 self._set_last(label, flags) 

251 records = self.load() 

252 for r in records: 252 ↛ 253line 252 didn't jump to line 253 because the loop on line 252 never started

253 if r.get("label") == label and r.get("flags") == flags: 

254 r["used"] = time.time() 

255 self.save(records) 

256 return 

257 

258 def forget(self, folders: list[str]): 

259 records = self.load() 

260 for r in records: 

261 r["folders"] = [f for f in r["folders"] if f not in folders] 

262 self.save([r for r in records if r["folders"]]) 

263 

264 

265def configurations(project_dir: Path, records: list[dict], 

266 last_used: tuple[str, str] | None = None) -> list[Configuration]: 

267 """Group the build folders into configurations, newest first. 

268 ``last_used`` is the (label, flags) of the GUI's last build.""" 

269 folders = {p.name: scan_folder(p) for p in build_folders(project_dir)} 

270 _, current_compile = read_compile_commands(project_dir) 

271 configs: list[Configuration] = [] 

272 claimed: set[str] = set() 

273 for r in records: 

274 present = [folders[n] for n in r["folders"] if n in folders and n not in claimed] 

275 if not present: 275 ↛ 276line 275 didn't jump to line 276 because the condition on line 275 was never true

276 continue 

277 claimed.update(f.name for f in present) 

278 config = Configuration(r.get("label", "?"), present, True, r.get("flags", ""), 

279 r.get("built") or max(f.newest for f in present)) 

280 config.used = r.get("used") or config.built 

281 configs.append(config) 

282 # Folders without a record: group those written at about the same time. 

283 loose = sorted((f for n, f in folders.items() if n not in claimed), key=lambda f: f.newest) 

284 group: list[BuildFolder] = [] 

285 for f in loose + [None]: 

286 if f is not None and (not group or f.newest - group[-1].newest <= GROUP_GAP_SECONDS): 286 ↛ 287line 286 didn't jump to line 287 because the condition on line 286 was never true

287 group.append(f) 

288 continue 

289 if group: 289 ↛ 290line 289 didn't jump to line 290 because the condition on line 289 was never true

290 compilers = sorted({g.compiler for g in group}) 

291 configs.append(Configuration(f"{', '.join(compilers)} · built outside the app", 

292 group, False, "", max(g.newest for g in group))) 

293 group = [f] if f is not None else [] 

294 configs.sort(key=lambda c: c.built, reverse=True) 

295 # "Current": the configuration of the GUI's last build. If that build's 

296 # settings have no recorded folders (they were built outside the app), 

297 # nothing is marked. Without any GUI build, use the folder the latest 

298 # compile commands point at, else the newest. 

299 if last_used is not None: 

300 for c in configs: 

301 if c.recorded and (c.label, c.flags) == tuple(last_used): 301 ↛ 300line 301 didn't jump to line 300 because the condition on line 301 was always true

302 c.current = True 

303 break 

304 else: 

305 for c in configs: 305 ↛ 306line 305 didn't jump to line 306 because the loop on line 305 never started

306 if current_compile and any(f.name == current_compile for f in c.folders): 

307 c.current = True 

308 break 

309 else: 

310 if configs: 310 ↛ 311line 310 didn't jump to line 311 because the condition on line 310 was never true

311 configs[0].current = True 

312 return configs 

313 

314 

315def human_size(n: int) -> str: 

316 for unit in ("B", "KB", "MB", "GB"): 316 ↛ 320line 316 didn't jump to line 320 because the loop on line 316 didn't complete

317 if n < 1024 or unit == "GB": 

318 return f"{n:.0f} {unit}" if unit == "B" else f"{n:.1f} {unit}" 

319 n /= 1024 

320 return f"{n} B" 

321 

322 

323def human_age(t: float) -> str: 

324 seconds = max(0, time.time() - t) 

325 if seconds < 60: 

326 return "just now" 

327 for unit, size in (("day", 86400), ("hour", 3600), ("minute", 60)): 327 ↛ 331line 327 didn't jump to line 331 because the loop on line 327 didn't complete

328 if seconds >= size: 328 ↛ 327line 328 didn't jump to line 327 because the condition on line 328 was always true

329 n = int(seconds // size) 

330 return f"{n} {unit}{'s' * (n != 1)} ago" 

331 return "" 

332 

333 

334def delete_folders(paths: list[Path]) -> list[str]: 

335 """Remove build folders; returns error messages (empty if all went).""" 

336 errors = [] 

337 for p in paths: 

338 try: 

339 shutil.rmtree(p) 

340 except OSError as exc: 

341 errors.append(f"{p.name}: {exc}") 

342 return errors 

343 

344 

345# --------------------------------------------------------------------- view 

346NO_VALGRIND = ("Valgrind wasn't found (on the PATH). It checks a program's memory use (leaks, invalid reads " 

347 "and writes) on Linux: install it there (the project's pixi environment has it on Linux), " 

348 "or with the system's package manager.") 

349 

350 

351def valgrind_found() -> bool: 

352 return shutil.which("valgrind") is not None 

353 

354 

355VALGRIND_SUMMARY_RE = re.compile(r"^==\d+== ERROR SUMMARY: ([\d,]+) errors?", re.MULTILINE) 

356 

357 

358def valgrind_errors(output: str) -> int | None: 

359 """How many errors Valgrind's summaries (one per program it ran) in 

360 ``output`` say it found; None: there are none (it didn't run).""" 

361 counts = [int(n.replace(",", "")) for n in VALGRIND_SUMMARY_RE.findall(output)] 

362 return sum(counts) if counts else None 

363 

364 

365def executable_name(path: Path) -> str: 

366 """The program's (test's, example's) name, as fpm knows it (no ".exe").""" 

367 return path.stem if path.suffix.lower() == ".exe" else path.name 

368 

369 

370class ConfigCard(QFrame): 

371 run_requested = Signal(str) 

372 run_in_terminal_requested = Signal(str) 

373 debug_requested = Signal(str, str) # kind ("program", "test", "example"), name 

374 valgrind_requested = Signal(str, str) # kind, name 

375 reveal_requested = Signal(str) 

376 delete_requested = Signal(object) # Configuration 

377 

378 def __init__(self, config: Configuration, pal: dict): 

379 super().__init__() 

380 self.config = config 

381 self.rows: dict[QWidget, tuple[str, Path]] = {} # row label -> (kind, file), for its menu 

382 self.setObjectName("ConfigCard") 

383 accent = pal["accent1"] if config.current else pal["line"] 

384 self.setStyleSheet(f""" 

385 #ConfigCard {{ background: {pal['bg']}; border: 1px solid {accent}; 

386 border-radius: 10px; }} 

387 #ConfigCard QLabel {{ background: transparent; color: {pal['text']}; }} 

388 """) 

389 layout = QVBoxLayout(self) 

390 layout.setContentsMargins(14, 10, 14, 10) 

391 layout.setSpacing(6) 

392 

393 top = QHBoxLayout() 

394 title = QLabel(config.label) 

395 font = QFont(QApplication.font()) 

396 font.setBold(True) 

397 font.setPointSizeF(font.pointSizeF() + 1) 

398 title.setFont(font) 

399 top.addWidget(title) 

400 if config.current: 

401 pill = QLabel("current") 

402 pill.setStyleSheet(f"background: {pal['accent1']}; color: white; border-radius: 8px;" 

403 f" padding: 1px 8px; font-weight: 600;") 

404 top.addWidget(pill) 

405 top.addStretch(1) 

406 reveal = QToolButton() 

407 reveal.setText(reveal_label()) 

408 reveal.clicked.connect(lambda: self.reveal_requested.emit(str(config.folders[0].path))) 

409 delete = QToolButton() 

410 delete.setText("Delete…") 

411 delete.setToolTip("Delete this configuration's build folders to free disk space") 

412 delete.clicked.connect(lambda: self.delete_requested.emit(config)) 

413 top.addWidget(reveal) 

414 top.addWidget(delete) 

415 layout.addLayout(top) 

416 

417 muted = pal["muted"] 

418 bits = [f"built {human_age(config.built)}", human_size(config.size), 

419 f"{len(config.folders)} folder{'s' * (len(config.folders) != 1)}"] 

420 if config.modules: 

421 bits.append(f"{config.modules} modules") 

422 if config.objects: 

423 bits.append(f"{config.objects} compiled files") 

424 info = QLabel(f"<span style='color:{muted}'>" + " · ".join(bits) + "</span>") 

425 info.setToolTip("\n".join(f"{f.name} ({f.role}, {human_size(f.size)})" for f in config.folders)) 

426 layout.addWidget(info) 

427 if config.flags: 

428 flags = QLabel(f"<span style='color:{muted}'>flags:</span> <code>{config.flags}</code>") 

429 flags.setWordWrap(True) 

430 flags.setTextInteractionFlags(Qt.TextSelectableByMouse) 

431 layout.addWidget(flags) 

432 if not config.recorded: 432 ↛ 433line 432 didn't jump to line 433 because the condition on line 432 was never true

433 note = QLabel(f"<span style='color:{muted}'><i>No record of the settings used — " 

434 "built before this view existed, or by fpm outside the app.</i></span>") 

435 note.setWordWrap(True) 

436 layout.addWidget(note) 

437 

438 for lib in config.libraries: 

439 row = QLabel(f"📚 <b>{lib.name}</b> <span style='color:{muted}'>library · " 

440 f"{human_size(lib.stat().st_size) if lib.exists() else ''}</span>") 

441 row.setToolTip(str(lib)) 

442 self.rows[row] = ("library", lib) 

443 layout.addWidget(row) 

444 for kind, program in config.programs: 

445 row = QHBoxLayout() 

446 icon = {"program": "▶", "test": "🧪", "example": "💡"}.get(kind, "•") 

447 label = QLabel(f"{icon} <b>{program.name}</b> <span style='color:{muted}'>{kind}</span>") 

448 label.setToolTip(str(program)) 

449 self.rows[label] = (kind, program) 

450 row.addWidget(label) 

451 row.addStretch(1) 

452 run = self._icon_button("Run", "run", pal) 

453 run.setToolTip(f"Run {program} directly (no rebuild), with the Program args") 

454 run.clicked.connect(lambda _=False, p=str(program): self.run_requested.emit(p)) 

455 row.addWidget(run) 

456 name = executable_name(program) 

457 debug = self._icon_button("Debug", "bug", pal) 

458 debug.setToolTip(f"Debug {name}: built again with the Build options (the debug profile, if none is " 

459 "chosen), then run under the debugger (Debug menu)") 

460 debug.clicked.connect(lambda _=False, k=kind, n=name: self.debug_requested.emit(k, n)) 

461 row.addWidget(debug) 

462 valgrind = self._icon_button("Valgrind", "memcheck", pal) 

463 if valgrind_found(): 

464 valgrind.setToolTip(f"Valgrind: check {name}'s memory use: built again with the Build options (the debug " 

465 "profile, if none is chosen), then run by fpm under Valgrind (leaks, invalid " 

466 "reads and writes; the report goes to Output)") 

467 else: 

468 valgrind.setEnabled(False) 

469 valgrind.setToolTip(NO_VALGRIND) 

470 valgrind.clicked.connect(lambda _=False, k=kind, n=name: self.valgrind_requested.emit(k, n)) 

471 row.addWidget(valgrind) 

472 layout.addLayout(row) 

473 if not config.programs and not config.libraries: 

474 layout.addWidget(QLabel(f"<span style='color:{muted}'>No programs or library here " 

475 "(compiled files only).</span>")) 

476 

477 @staticmethod 

478 def _icon_button(text: str, icon: str, pal: dict) -> QToolButton: 

479 """A program's Run, Debug or Valgrind button: its icon (the text kept, 

480 for screen readers; the tooltip says what it does).""" 

481 button = QToolButton() 

482 button.setText(text) 

483 button.setIcon(make_icon(icon, pal["text"])) 

484 button.setIconSize(QSize(18, 18)) 

485 button.setToolButtonStyle(Qt.ToolButtonIconOnly) 

486 button.setAutoRaise(True) 

487 button.setAttribute(Qt.WA_LayoutUsesWidgetRect) 

488 return button 

489 

490 def context_menu(self, pos) -> QMenu: 

491 """The right-click menu at ``pos``: for a program or library when it's 

492 on one, else for the whole configuration.""" 

493 menu = QMenu(self) 

494 child = self.childAt(pos) 

495 if child in self.rows: 

496 kind, path = self.rows[child] 

497 if kind != "library": 

498 menu.addAction("Run", lambda: self.run_requested.emit(str(path))) 

499 action = menu.addAction("Run in Terminal", lambda: self.run_in_terminal_requested.emit(str(path))) 

500 action.setToolTip("Type the command in the Terminal panel (for programs that read input)") 

501 menu.addAction("Debug", lambda: self.debug_requested.emit(kind, executable_name(path))) 

502 action = menu.addAction("Run with Valgrind", 

503 lambda: self.valgrind_requested.emit(kind, executable_name(path))) 

504 action.setEnabled(valgrind_found()) 

505 menu.addAction(reveal_label(), lambda: self.reveal_requested.emit(str(path))) 

506 menu.addAction("Copy Path", lambda: QApplication.clipboard().setText(str(path))) 

507 return menu 

508 folder = self.config.folders[0].path 

509 menu.addAction(reveal_label(), lambda: self.reveal_requested.emit(str(folder))) 

510 if self.config.flags: 510 ↛ 512line 510 didn't jump to line 512 because the condition on line 510 was always true

511 menu.addAction("Copy Flags", lambda: QApplication.clipboard().setText(self.config.flags)) 

512 menu.addSeparator() 

513 menu.addAction("Delete…", lambda: self.delete_requested.emit(self.config)) 

514 return menu 

515 

516 def contextMenuEvent(self, event): 

517 menu = self.context_menu(event.pos()) 

518 menu.exec(event.globalPos()) 

519 menu.deleteLater() 

520 

521 

522class BuildOutputsView(QWidget): 

523 """The "Build outputs" tab.""" 

524 

525 run_requested = Signal(str) 

526 run_in_terminal_requested = Signal(str) 

527 debug_requested = Signal(str, str) # kind, name (see ConfigCard) 

528 valgrind_requested = Signal(str, str) # kind, name 

529 reveal_requested = Signal(str) 

530 delete_requested = Signal(object) # list[Configuration] 

531 refresh_requested = Signal() 

532 

533 def __init__(self, parent=None): 

534 super().__init__(parent) 

535 self.configs: list[Configuration] = [] 

536 self.project_dir: Path | None = None 

537 self.summary = QLabel() 

538 refresh = QToolButton() 

539 refresh.setText("Refresh") 

540 refresh.clicked.connect(self.refresh_requested) 

541 self.prune_button = QToolButton() 

542 self.prune_button.setText("Delete All Except Current…") 

543 self.prune_button.clicked.connect( 

544 lambda: self.delete_requested.emit([c for c in self.configs if not c.current])) 

545 header = QHBoxLayout() 

546 header.addWidget(self.summary, 1) 

547 header.addWidget(self.prune_button) 

548 header.addWidget(refresh) 

549 

550 self.cards = QWidget() 

551 self.cards_layout = QVBoxLayout(self.cards) 

552 self.cards_layout.setContentsMargins(0, 0, 0, 0) 

553 self.cards_layout.setSpacing(10) 

554 scroll = QScrollArea() 

555 scroll.setWidgetResizable(True) 

556 scroll.setFrameShape(QFrame.NoFrame) 

557 scroll.setWidget(self.cards) 

558 self.empty = QLabel() 

559 self.empty.setAlignment(Qt.AlignCenter) 

560 self.empty.setWordWrap(True) 

561 self.empty.setStyleSheet("color: gray;") 

562 

563 layout = QVBoxLayout(self) 

564 layout.setContentsMargins(10, 8, 10, 8) 

565 layout.addLayout(header) 

566 layout.addWidget(scroll, 1) 

567 layout.addWidget(self.empty, 1) 

568 self._scroll = scroll 

569 self.set_configurations([], None) 

570 

571 def refresh_palette(self): 

572 """The theme changed: the cards drawn again in its colors (the same 

573 configurations: the build folders aren't read again).""" 

574 self.set_configurations(self.configs, self.project_dir) 

575 

576 def set_configurations(self, configs: list[Configuration], project_dir: Path | None): 

577 self.configs, self.project_dir = configs, project_dir 

578 while self.cards_layout.count(): 

579 item = self.cards_layout.takeAt(0) 

580 if item.widget(): 

581 # Hide now: deleteLater only happens on a later event-loop pass. 

582 item.widget().hide() 

583 item.widget().setParent(None) 

584 item.widget().deleteLater() 

585 pal = PALETTES["light" if app_is_light() else "dark"] 

586 for config in configs: 

587 card = ConfigCard(config, pal) 

588 card.run_requested.connect(self.run_requested) 

589 card.run_in_terminal_requested.connect(self.run_in_terminal_requested) 

590 card.debug_requested.connect(self.debug_requested) 

591 card.valgrind_requested.connect(self.valgrind_requested) 

592 card.reveal_requested.connect(self.reveal_requested) 

593 card.delete_requested.connect(lambda c: self.delete_requested.emit([c])) 

594 card.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Maximum) 

595 self.cards_layout.addWidget(card) 

596 self.cards_layout.addStretch(1) 

597 has_current = any(c.current for c in configs) 

598 total = sum(c.size for c in configs) 

599 folders = sum(len(c.folders) for c in configs) 

600 if project_dir is None: 

601 message = "Open an fpm.toml to see its build outputs." 

602 elif not configs: 

603 message = "Nothing has been built yet (no build/<compiler>_<hash> folders)." 

604 else: 

605 message = "" 

606 summary = (f"<b>{len(configs)} configuration{'s' * (len(configs) != 1)}</b> · " 

607 f"{folders} folders · {human_size(total)}") if configs else "" 

608 if configs and not has_current: 608 ↛ 609line 608 didn't jump to line 609 because the condition on line 608 was never true

609 summary += ("<br><span style='color:gray'>The settings of the last build aren't " 

610 "recorded yet (their folders were built outside the app) — Clean and " 

611 "build again to record them.</span>") 

612 self.summary.setText(summary) 

613 self.prune_button.setEnabled(len(configs) > 1 and has_current) 

614 self.prune_button.setToolTip("" if has_current or not configs else 

615 "No configuration is marked current yet") 

616 self.empty.setText(message) 

617 self.empty.setVisible(bool(message)) 

618 self._scroll.setVisible(not message) 

619