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
« 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.
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.
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.
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"""
19from __future__ import annotations
21import json
22import os
23import re
24import shlex
25import shutil
26import time
27from dataclasses import dataclass, field
28from pathlib import Path
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)
45from .build_monitor import PALETTES, app_is_light
46from .compat import WINDOWS, quote_arg, reveal_label
47from .icons import make_icon
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
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
65 @property
66 def name(self) -> str:
67 return self.path.name
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"
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
90 @property
91 def size(self) -> int:
92 return sum(f.size for f in self.folders)
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))
99 @property
100 def libraries(self) -> list[Path]:
101 return [lib for f in self.folders for lib in f.libraries]
103 @property
104 def modules(self) -> int:
105 return sum(f.modules for f in self.folders)
107 @property
108 def objects(self) -> int:
109 return sum(f.objects for f in self.folders)
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))
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
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
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)}
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))
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
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)
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}, ...]."""
208 def __init__(self, settings, key: str):
209 self.settings, self.key = settings, key
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 []
218 def save(self, records: list[dict]):
219 self.settings.setValue(self.key, json.dumps(records))
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
229 def _set_last(self, label: str, flags: str):
230 self.settings.setValue(self.key + "-last", json.dumps({"label": label, "flags": flags}))
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"]])
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
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"]])
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
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"
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 ""
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
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.")
351def valgrind_found() -> bool:
352 return shutil.which("valgrind") is not None
355VALGRIND_SUMMARY_RE = re.compile(r"^==\d+== ERROR SUMMARY: ([\d,]+) errors?", re.MULTILINE)
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
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
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
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)
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)
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)
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>"))
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
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
516 def contextMenuEvent(self, event):
517 menu = self.context_menu(event.pos())
518 menu.exec(event.globalPos())
519 menu.deleteLater()
522class BuildOutputsView(QWidget):
523 """The "Build outputs" tab."""
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()
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)
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;")
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)
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)
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)