vibefpmgui.build_outputs
What fpm has left in a project's build/ folder, and a tab to show it.
fpm splits one build over several folders named <compiler>_<hash>, where
the hash covers the settings that affect that step: one folder for compiled
files and modules, one for the library archive, one for linked programs. A
build with other settings (profile, flags, features) adds another set and old
sets are never removed.
The hashes can't be turned back into settings, so the GUI records, for every
build it runs, which folders that build created or changed together with the
settings used (ConfigRecords). Folders it has no record for (built
before this feature, or by fpm on the command line) are grouped by when they
were last written.
build/compile_commands.json (the exact compiler command for each source
file of the most recent build) is read by read_compile_commands.
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": 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: 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): 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: 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: 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: 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: 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): 287 group.append(f) 288 continue 289 if group: 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): 302 c.current = True 303 break 304 else: 305 for c in configs: 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: 311 configs[0].current = True 312 return configs 313 314 315def human_size(n: int) -> str: 316 for unit in ("B", "KB", "MB", "GB"): 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)): 328 if seconds >= size: 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: 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: 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: 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)
55@dataclass 56class BuildFolder: 57 path: Path 58 compiler: str 59 newest: float = 0.0 # newest file modification time 60 size: int = 0 # bytes 61 programs: list[tuple[str, Path]] = field(default_factory=list) # (kind, path) 62 libraries: list[Path] = field(default_factory=list) 63 modules: int = 0 64 objects: int = 0 65 66 @property 67 def name(self) -> str: 68 return self.path.name 69 70 @property 71 def role(self) -> str: 72 if self.programs: 73 return "programs" 74 if self.objects or self.modules: 75 return "compiled files" 76 if self.libraries: 77 return "library" 78 return "other"
81@dataclass 82class Configuration: 83 label: str # e.g. "release · gfortran" 84 folders: list[BuildFolder] 85 recorded: bool # built by the GUI with known settings 86 flags: str = "" 87 built: float = 0.0 88 used: float = 0.0 # last built or found up to date by the GUI 89 current: bool = False 90 91 @property 92 def size(self) -> int: 93 return sum(f.size for f in self.folders) 94 95 @property 96 def programs(self) -> list[tuple[str, Path]]: 97 return sorted((p for f in self.folders for p in f.programs), 98 key=lambda kp: (list(PROGRAM_KINDS.values()).index(kp[0]), kp[1].name)) 99 100 @property 101 def libraries(self) -> list[Path]: 102 return [lib for f in self.folders for lib in f.libraries] 103 104 @property 105 def modules(self) -> int: 106 return sum(f.modules for f in self.folders) 107 108 @property 109 def objects(self) -> int: 110 return sum(f.objects for f in self.folders)
127def scan_folder(path: Path) -> BuildFolder: 128 match = BUILD_DIR_RE.match(path.name) 129 folder = BuildFolder(path, match.group("compiler") if match else "?") 130 for dirpath, _dirs, files in os.walk(path): 131 rel_top = Path(dirpath).relative_to(path).parts[:1] 132 for name in files: 133 file = Path(dirpath) / name 134 try: 135 stat = file.stat() 136 except OSError: 137 continue 138 folder.size += stat.st_size 139 folder.newest = max(folder.newest, stat.st_mtime) 140 suffix = file.suffix.lower() 141 if suffix in (".mod", ".smod"): 142 folder.modules += 1 143 elif suffix in (".o", ".obj"): 144 folder.objects += 1 145 elif suffix in (".a", ".lib") or (suffix in (".so", ".dylib", ".dll") and name.startswith("lib")): 146 folder.libraries.append(file) 147 elif rel_top and rel_top[0] in PROGRAM_KINDS and _is_program(file): 148 folder.programs.append((PROGRAM_KINDS[rel_top[0]], file)) 149 return folder
152def snapshot(project_dir: Path) -> dict[str, float]: 153 """Folder name -> newest file time, to see what a build changed.""" 154 return {p.name: scan_folder(p).newest for p in build_folders(project_dir)}
Folder name -> newest file time, to see what a build changed.
162def read_compile_commands(project_dir: Path) -> tuple[dict[str, list[str]], str | None]: 163 """{resolved source path: argument list} from build/compile_commands.json, 164 plus the name of the build folder the commands compile into.""" 165 path = project_dir / "build" / "compile_commands.json" 166 try: 167 entries = json.loads(path.read_text()) 168 except (OSError, ValueError): 169 return {}, None 170 commands: dict[str, list[str]] = {} 171 folder = None 172 for entry in entries if isinstance(entries, list) else []: 173 if not isinstance(entry, dict) or "file" not in entry: 174 continue 175 args = entry.get("arguments") or shlex.split(entry.get("command", "")) 176 base = Path(entry.get("directory") or project_dir) 177 source = (base / entry["file"]).resolve() 178 commands[str(source)] = list(args) 179 if folder is None and "-J" in args: 180 j = args.index("-J") 181 if j + 1 < len(args): 182 folder = Path(args[j + 1]).name 183 return commands, folder
{resolved source path: argument list} from build/compile_commands.json, plus the name of the build folder the commands compile into.
186def format_command(args: list[str]) -> str: 187 """A shell-quoted command, one option per line for readability.""" 188 if not args: 189 return "" 190 lines = [quote_arg(args[0])] 191 i = 1 192 while i < len(args): 193 part = quote_arg(args[i]) 194 # Keep an option and its value together ("-o file", "-J dir"). 195 if args[i] in ("-o", "-J", "-I", "-c") and i + 1 < len(args): 196 part += " " + quote_arg(args[i + 1]) 197 i += 1 198 lines.append(part) 199 i += 1 200 # Line continuation: "\" in POSIX shells, "^" in the Windows shell. 201 return f" {'^' if WINDOWS else chr(92)}\n ".join(lines)
A shell-quoted command, one option per line for readability.
205class ConfigRecords: 206 """Per-project record of which folders each GUI build wrote, stored in 207 the app's settings as JSON: [{label, flags, folders, built}, ...].""" 208 209 def __init__(self, settings, key: str): 210 self.settings, self.key = settings, key 211 212 def load(self) -> list[dict]: 213 try: 214 data = json.loads(self.settings.value(self.key, "[]")) 215 return [r for r in data if isinstance(r, dict) and r.get("folders")] 216 except (TypeError, ValueError): 217 return [] 218 219 def save(self, records: list[dict]): 220 self.settings.setValue(self.key, json.dumps(records)) 221 222 def last_used(self) -> tuple[str, str] | None: 223 """(label, flags) of the last build the GUI ran, if any.""" 224 try: 225 data = json.loads(self.settings.value(self.key + "-last", "null")) 226 return (data["label"], data["flags"]) if data else None 227 except (TypeError, ValueError, KeyError): 228 return None 229 230 def _set_last(self, label: str, flags: str): 231 self.settings.setValue(self.key + "-last", json.dumps({"label": label, "flags": flags})) 232 233 def record_build(self, label: str, flags: str, folders: list[str]): 234 """``folders`` were just written by a build with these settings.""" 235 self._set_last(label, flags) 236 if not folders: 237 return 238 records = self.load() 239 for r in records: # a folder belongs to one configuration only 240 r["folders"] = [f for f in r["folders"] if f not in folders] 241 existing = next((r for r in records if r.get("label") == label and r.get("flags") == flags), None) 242 if existing is None: 243 existing = {"label": label, "flags": flags, "folders": []} 244 records.append(existing) 245 existing["folders"] = sorted(set(existing["folders"]) | set(folders)) 246 existing["built"] = existing["used"] = time.time() 247 self.save([r for r in records if r["folders"]]) 248 249 def mark_used(self, label: str, flags: str): 250 """A build with these settings found nothing to do: it's still current.""" 251 self._set_last(label, flags) 252 records = self.load() 253 for r in records: 254 if r.get("label") == label and r.get("flags") == flags: 255 r["used"] = time.time() 256 self.save(records) 257 return 258 259 def forget(self, folders: list[str]): 260 records = self.load() 261 for r in records: 262 r["folders"] = [f for f in r["folders"] if f not in folders] 263 self.save([r for r in records if r["folders"]])
Per-project record of which folders each GUI build wrote, stored in the app's settings as JSON: [{label, flags, folders, built}, ...].
222 def last_used(self) -> tuple[str, str] | None: 223 """(label, flags) of the last build the GUI ran, if any.""" 224 try: 225 data = json.loads(self.settings.value(self.key + "-last", "null")) 226 return (data["label"], data["flags"]) if data else None 227 except (TypeError, ValueError, KeyError): 228 return None
(label, flags) of the last build the GUI ran, if any.
233 def record_build(self, label: str, flags: str, folders: list[str]): 234 """``folders`` were just written by a build with these settings.""" 235 self._set_last(label, flags) 236 if not folders: 237 return 238 records = self.load() 239 for r in records: # a folder belongs to one configuration only 240 r["folders"] = [f for f in r["folders"] if f not in folders] 241 existing = next((r for r in records if r.get("label") == label and r.get("flags") == flags), None) 242 if existing is None: 243 existing = {"label": label, "flags": flags, "folders": []} 244 records.append(existing) 245 existing["folders"] = sorted(set(existing["folders"]) | set(folders)) 246 existing["built"] = existing["used"] = time.time() 247 self.save([r for r in records if r["folders"]])
folders were just written by a build with these settings.
249 def mark_used(self, label: str, flags: str): 250 """A build with these settings found nothing to do: it's still current.""" 251 self._set_last(label, flags) 252 records = self.load() 253 for r in records: 254 if r.get("label") == label and r.get("flags") == flags: 255 r["used"] = time.time() 256 self.save(records) 257 return
A build with these settings found nothing to do: it's still current.
266def configurations(project_dir: Path, records: list[dict], 267 last_used: tuple[str, str] | None = None) -> list[Configuration]: 268 """Group the build folders into configurations, newest first. 269 ``last_used`` is the (label, flags) of the GUI's last build.""" 270 folders = {p.name: scan_folder(p) for p in build_folders(project_dir)} 271 _, current_compile = read_compile_commands(project_dir) 272 configs: list[Configuration] = [] 273 claimed: set[str] = set() 274 for r in records: 275 present = [folders[n] for n in r["folders"] if n in folders and n not in claimed] 276 if not present: 277 continue 278 claimed.update(f.name for f in present) 279 config = Configuration(r.get("label", "?"), present, True, r.get("flags", ""), 280 r.get("built") or max(f.newest for f in present)) 281 config.used = r.get("used") or config.built 282 configs.append(config) 283 # Folders without a record: group those written at about the same time. 284 loose = sorted((f for n, f in folders.items() if n not in claimed), key=lambda f: f.newest) 285 group: list[BuildFolder] = [] 286 for f in loose + [None]: 287 if f is not None and (not group or f.newest - group[-1].newest <= GROUP_GAP_SECONDS): 288 group.append(f) 289 continue 290 if group: 291 compilers = sorted({g.compiler for g in group}) 292 configs.append(Configuration(f"{', '.join(compilers)} · built outside the app", 293 group, False, "", max(g.newest for g in group))) 294 group = [f] if f is not None else [] 295 configs.sort(key=lambda c: c.built, reverse=True) 296 # "Current": the configuration of the GUI's last build. If that build's 297 # settings have no recorded folders (they were built outside the app), 298 # nothing is marked. Without any GUI build, use the folder the latest 299 # compile commands point at, else the newest. 300 if last_used is not None: 301 for c in configs: 302 if c.recorded and (c.label, c.flags) == tuple(last_used): 303 c.current = True 304 break 305 else: 306 for c in configs: 307 if current_compile and any(f.name == current_compile for f in c.folders): 308 c.current = True 309 break 310 else: 311 if configs: 312 configs[0].current = True 313 return configs
Group the build folders into configurations, newest first.
last_used is the (label, flags) of the GUI's last build.
335def delete_folders(paths: list[Path]) -> list[str]: 336 """Remove build folders; returns error messages (empty if all went).""" 337 errors = [] 338 for p in paths: 339 try: 340 shutil.rmtree(p) 341 except OSError as exc: 342 errors.append(f"{p.name}: {exc}") 343 return errors
Remove build folders; returns error messages (empty if all went).
359def valgrind_errors(output: str) -> int | None: 360 """How many errors Valgrind's summaries (one per program it ran) in 361 ``output`` say it found; None: there are none (it didn't run).""" 362 counts = [int(n.replace(",", "")) for n in VALGRIND_SUMMARY_RE.findall(output)] 363 return sum(counts) if counts else None
How many errors Valgrind's summaries (one per program it ran) in
output say it found; None: there are none (it didn't run).
366def executable_name(path: Path) -> str: 367 """The program's (test's, example's) name, as fpm knows it (no ".exe").""" 368 return path.stem if path.suffix.lower() == ".exe" else path.name
The program's (test's, example's) name, as fpm knows it (no ".exe").
371class ConfigCard(QFrame): 372 run_requested = Signal(str) 373 run_in_terminal_requested = Signal(str) 374 debug_requested = Signal(str, str) # kind ("program", "test", "example"), name 375 valgrind_requested = Signal(str, str) # kind, name 376 reveal_requested = Signal(str) 377 delete_requested = Signal(object) # Configuration 378 379 def __init__(self, config: Configuration, pal: dict): 380 super().__init__() 381 self.config = config 382 self.rows: dict[QWidget, tuple[str, Path]] = {} # row label -> (kind, file), for its menu 383 self.setObjectName("ConfigCard") 384 accent = pal["accent1"] if config.current else pal["line"] 385 self.setStyleSheet(f""" 386 #ConfigCard {{ background: {pal['bg']}; border: 1px solid {accent}; 387 border-radius: 10px; }} 388 #ConfigCard QLabel {{ background: transparent; color: {pal['text']}; }} 389 """) 390 layout = QVBoxLayout(self) 391 layout.setContentsMargins(14, 10, 14, 10) 392 layout.setSpacing(6) 393 394 top = QHBoxLayout() 395 title = QLabel(config.label) 396 font = QFont(QApplication.font()) 397 font.setBold(True) 398 font.setPointSizeF(font.pointSizeF() + 1) 399 title.setFont(font) 400 top.addWidget(title) 401 if config.current: 402 pill = QLabel("current") 403 pill.setStyleSheet(f"background: {pal['accent1']}; color: white; border-radius: 8px;" 404 f" padding: 1px 8px; font-weight: 600;") 405 top.addWidget(pill) 406 top.addStretch(1) 407 reveal = QToolButton() 408 reveal.setText(reveal_label()) 409 reveal.clicked.connect(lambda: self.reveal_requested.emit(str(config.folders[0].path))) 410 delete = QToolButton() 411 delete.setText("Delete…") 412 delete.setToolTip("Delete this configuration's build folders to free disk space") 413 delete.clicked.connect(lambda: self.delete_requested.emit(config)) 414 top.addWidget(reveal) 415 top.addWidget(delete) 416 layout.addLayout(top) 417 418 muted = pal["muted"] 419 bits = [f"built {human_age(config.built)}", human_size(config.size), 420 f"{len(config.folders)} folder{'s' * (len(config.folders) != 1)}"] 421 if config.modules: 422 bits.append(f"{config.modules} modules") 423 if config.objects: 424 bits.append(f"{config.objects} compiled files") 425 info = QLabel(f"<span style='color:{muted}'>" + " · ".join(bits) + "</span>") 426 info.setToolTip("\n".join(f"{f.name} ({f.role}, {human_size(f.size)})" for f in config.folders)) 427 layout.addWidget(info) 428 if config.flags: 429 flags = QLabel(f"<span style='color:{muted}'>flags:</span> <code>{config.flags}</code>") 430 flags.setWordWrap(True) 431 flags.setTextInteractionFlags(Qt.TextSelectableByMouse) 432 layout.addWidget(flags) 433 if not config.recorded: 434 note = QLabel(f"<span style='color:{muted}'><i>No record of the settings used — " 435 "built before this view existed, or by fpm outside the app.</i></span>") 436 note.setWordWrap(True) 437 layout.addWidget(note) 438 439 for lib in config.libraries: 440 row = QLabel(f"📚 <b>{lib.name}</b> <span style='color:{muted}'>library · " 441 f"{human_size(lib.stat().st_size) if lib.exists() else ''}</span>") 442 row.setToolTip(str(lib)) 443 self.rows[row] = ("library", lib) 444 layout.addWidget(row) 445 for kind, program in config.programs: 446 row = QHBoxLayout() 447 icon = {"program": "▶", "test": "🧪", "example": "💡"}.get(kind, "•") 448 label = QLabel(f"{icon} <b>{program.name}</b> <span style='color:{muted}'>{kind}</span>") 449 label.setToolTip(str(program)) 450 self.rows[label] = (kind, program) 451 row.addWidget(label) 452 row.addStretch(1) 453 run = self._icon_button("Run", "run", pal) 454 run.setToolTip(f"Run {program} directly (no rebuild), with the Program args") 455 run.clicked.connect(lambda _=False, p=str(program): self.run_requested.emit(p)) 456 row.addWidget(run) 457 name = executable_name(program) 458 debug = self._icon_button("Debug", "bug", pal) 459 debug.setToolTip(f"Debug {name}: built again with the Build options (the debug profile, if none is " 460 "chosen), then run under the debugger (Debug menu)") 461 debug.clicked.connect(lambda _=False, k=kind, n=name: self.debug_requested.emit(k, n)) 462 row.addWidget(debug) 463 valgrind = self._icon_button("Valgrind", "memcheck", pal) 464 if valgrind_found(): 465 valgrind.setToolTip(f"Valgrind: check {name}'s memory use: built again with the Build options (the debug " 466 "profile, if none is chosen), then run by fpm under Valgrind (leaks, invalid " 467 "reads and writes; the report goes to Output)") 468 else: 469 valgrind.setEnabled(False) 470 valgrind.setToolTip(NO_VALGRIND) 471 valgrind.clicked.connect(lambda _=False, k=kind, n=name: self.valgrind_requested.emit(k, n)) 472 row.addWidget(valgrind) 473 layout.addLayout(row) 474 if not config.programs and not config.libraries: 475 layout.addWidget(QLabel(f"<span style='color:{muted}'>No programs or library here " 476 "(compiled files only).</span>")) 477 478 @staticmethod 479 def _icon_button(text: str, icon: str, pal: dict) -> QToolButton: 480 """A program's Run, Debug or Valgrind button: its icon (the text kept, 481 for screen readers; the tooltip says what it does).""" 482 button = QToolButton() 483 button.setText(text) 484 button.setIcon(make_icon(icon, pal["text"])) 485 button.setIconSize(QSize(18, 18)) 486 button.setToolButtonStyle(Qt.ToolButtonIconOnly) 487 button.setAutoRaise(True) 488 button.setAttribute(Qt.WA_LayoutUsesWidgetRect) 489 return button 490 491 def context_menu(self, pos) -> QMenu: 492 """The right-click menu at ``pos``: for a program or library when it's 493 on one, else for the whole configuration.""" 494 menu = QMenu(self) 495 child = self.childAt(pos) 496 if child in self.rows: 497 kind, path = self.rows[child] 498 if kind != "library": 499 menu.addAction("Run", lambda: self.run_requested.emit(str(path))) 500 action = menu.addAction("Run in Terminal", lambda: self.run_in_terminal_requested.emit(str(path))) 501 action.setToolTip("Type the command in the Terminal panel (for programs that read input)") 502 menu.addAction("Debug", lambda: self.debug_requested.emit(kind, executable_name(path))) 503 action = menu.addAction("Run with Valgrind", 504 lambda: self.valgrind_requested.emit(kind, executable_name(path))) 505 action.setEnabled(valgrind_found()) 506 menu.addAction(reveal_label(), lambda: self.reveal_requested.emit(str(path))) 507 menu.addAction("Copy Path", lambda: QApplication.clipboard().setText(str(path))) 508 return menu 509 folder = self.config.folders[0].path 510 menu.addAction(reveal_label(), lambda: self.reveal_requested.emit(str(folder))) 511 if self.config.flags: 512 menu.addAction("Copy Flags", lambda: QApplication.clipboard().setText(self.config.flags)) 513 menu.addSeparator() 514 menu.addAction("Delete…", lambda: self.delete_requested.emit(self.config)) 515 return menu 516 517 def contextMenuEvent(self, event): 518 menu = self.context_menu(event.pos()) 519 menu.exec(event.globalPos()) 520 menu.deleteLater()
QFrame(self, /, parent: PySide6.QtWidgets.QWidget | None = None, f: PySide6.QtCore.Qt.WindowType = Default(Qt.WindowFlags), *, frameShape: PySide6.QtWidgets.QFrame.Shape | None = None, frameShadow: PySide6.QtWidgets.QFrame.Shadow | None = None, lineWidth: int | None = None, midLineWidth: int | None = None, frameWidth: int | None = None, frameRect: PySide6.QtCore.QRect | None = None) -> None
379 def __init__(self, config: Configuration, pal: dict): 380 super().__init__() 381 self.config = config 382 self.rows: dict[QWidget, tuple[str, Path]] = {} # row label -> (kind, file), for its menu 383 self.setObjectName("ConfigCard") 384 accent = pal["accent1"] if config.current else pal["line"] 385 self.setStyleSheet(f""" 386 #ConfigCard {{ background: {pal['bg']}; border: 1px solid {accent}; 387 border-radius: 10px; }} 388 #ConfigCard QLabel {{ background: transparent; color: {pal['text']}; }} 389 """) 390 layout = QVBoxLayout(self) 391 layout.setContentsMargins(14, 10, 14, 10) 392 layout.setSpacing(6) 393 394 top = QHBoxLayout() 395 title = QLabel(config.label) 396 font = QFont(QApplication.font()) 397 font.setBold(True) 398 font.setPointSizeF(font.pointSizeF() + 1) 399 title.setFont(font) 400 top.addWidget(title) 401 if config.current: 402 pill = QLabel("current") 403 pill.setStyleSheet(f"background: {pal['accent1']}; color: white; border-radius: 8px;" 404 f" padding: 1px 8px; font-weight: 600;") 405 top.addWidget(pill) 406 top.addStretch(1) 407 reveal = QToolButton() 408 reveal.setText(reveal_label()) 409 reveal.clicked.connect(lambda: self.reveal_requested.emit(str(config.folders[0].path))) 410 delete = QToolButton() 411 delete.setText("Delete…") 412 delete.setToolTip("Delete this configuration's build folders to free disk space") 413 delete.clicked.connect(lambda: self.delete_requested.emit(config)) 414 top.addWidget(reveal) 415 top.addWidget(delete) 416 layout.addLayout(top) 417 418 muted = pal["muted"] 419 bits = [f"built {human_age(config.built)}", human_size(config.size), 420 f"{len(config.folders)} folder{'s' * (len(config.folders) != 1)}"] 421 if config.modules: 422 bits.append(f"{config.modules} modules") 423 if config.objects: 424 bits.append(f"{config.objects} compiled files") 425 info = QLabel(f"<span style='color:{muted}'>" + " · ".join(bits) + "</span>") 426 info.setToolTip("\n".join(f"{f.name} ({f.role}, {human_size(f.size)})" for f in config.folders)) 427 layout.addWidget(info) 428 if config.flags: 429 flags = QLabel(f"<span style='color:{muted}'>flags:</span> <code>{config.flags}</code>") 430 flags.setWordWrap(True) 431 flags.setTextInteractionFlags(Qt.TextSelectableByMouse) 432 layout.addWidget(flags) 433 if not config.recorded: 434 note = QLabel(f"<span style='color:{muted}'><i>No record of the settings used — " 435 "built before this view existed, or by fpm outside the app.</i></span>") 436 note.setWordWrap(True) 437 layout.addWidget(note) 438 439 for lib in config.libraries: 440 row = QLabel(f"📚 <b>{lib.name}</b> <span style='color:{muted}'>library · " 441 f"{human_size(lib.stat().st_size) if lib.exists() else ''}</span>") 442 row.setToolTip(str(lib)) 443 self.rows[row] = ("library", lib) 444 layout.addWidget(row) 445 for kind, program in config.programs: 446 row = QHBoxLayout() 447 icon = {"program": "▶", "test": "🧪", "example": "💡"}.get(kind, "•") 448 label = QLabel(f"{icon} <b>{program.name}</b> <span style='color:{muted}'>{kind}</span>") 449 label.setToolTip(str(program)) 450 self.rows[label] = (kind, program) 451 row.addWidget(label) 452 row.addStretch(1) 453 run = self._icon_button("Run", "run", pal) 454 run.setToolTip(f"Run {program} directly (no rebuild), with the Program args") 455 run.clicked.connect(lambda _=False, p=str(program): self.run_requested.emit(p)) 456 row.addWidget(run) 457 name = executable_name(program) 458 debug = self._icon_button("Debug", "bug", pal) 459 debug.setToolTip(f"Debug {name}: built again with the Build options (the debug profile, if none is " 460 "chosen), then run under the debugger (Debug menu)") 461 debug.clicked.connect(lambda _=False, k=kind, n=name: self.debug_requested.emit(k, n)) 462 row.addWidget(debug) 463 valgrind = self._icon_button("Valgrind", "memcheck", pal) 464 if valgrind_found(): 465 valgrind.setToolTip(f"Valgrind: check {name}'s memory use: built again with the Build options (the debug " 466 "profile, if none is chosen), then run by fpm under Valgrind (leaks, invalid " 467 "reads and writes; the report goes to Output)") 468 else: 469 valgrind.setEnabled(False) 470 valgrind.setToolTip(NO_VALGRIND) 471 valgrind.clicked.connect(lambda _=False, k=kind, n=name: self.valgrind_requested.emit(k, n)) 472 row.addWidget(valgrind) 473 layout.addLayout(row) 474 if not config.programs and not config.libraries: 475 layout.addWidget(QLabel(f"<span style='color:{muted}'>No programs or library here " 476 "(compiled files only).</span>"))
__init__(self, /, parent: PySide6.QtWidgets.QWidget | None = None, f: PySide6.QtCore.Qt.WindowType = Default(Qt.WindowFlags), *, frameShape: PySide6.QtWidgets.QFrame.Shape | None = None, frameShadow: PySide6.QtWidgets.QFrame.Shadow | None = None, lineWidth: int | None = None, midLineWidth: int | None = None, frameWidth: int | None = None, frameRect: PySide6.QtCore.QRect | None = None) -> None
Initialize self. See help(type(self)) for accurate signature.
517 def contextMenuEvent(self, event): 518 menu = self.context_menu(event.pos()) 519 menu.exec(event.globalPos()) 520 menu.deleteLater()
contextMenuEvent(self, event: PySide6.QtGui.QContextMenuEvent, /) -> None
523class BuildOutputsView(QWidget): 524 """The "Build outputs" tab.""" 525 526 run_requested = Signal(str) 527 run_in_terminal_requested = Signal(str) 528 debug_requested = Signal(str, str) # kind, name (see ConfigCard) 529 valgrind_requested = Signal(str, str) # kind, name 530 reveal_requested = Signal(str) 531 delete_requested = Signal(object) # list[Configuration] 532 refresh_requested = Signal() 533 534 def __init__(self, parent=None): 535 super().__init__(parent) 536 self.configs: list[Configuration] = [] 537 self.project_dir: Path | None = None 538 self.summary = QLabel() 539 refresh = QToolButton() 540 refresh.setText("Refresh") 541 refresh.clicked.connect(self.refresh_requested) 542 self.prune_button = QToolButton() 543 self.prune_button.setText("Delete All Except Current…") 544 self.prune_button.clicked.connect( 545 lambda: self.delete_requested.emit([c for c in self.configs if not c.current])) 546 header = QHBoxLayout() 547 header.addWidget(self.summary, 1) 548 header.addWidget(self.prune_button) 549 header.addWidget(refresh) 550 551 self.cards = QWidget() 552 self.cards_layout = QVBoxLayout(self.cards) 553 self.cards_layout.setContentsMargins(0, 0, 0, 0) 554 self.cards_layout.setSpacing(10) 555 scroll = QScrollArea() 556 scroll.setWidgetResizable(True) 557 scroll.setFrameShape(QFrame.NoFrame) 558 scroll.setWidget(self.cards) 559 self.empty = QLabel() 560 self.empty.setAlignment(Qt.AlignCenter) 561 self.empty.setWordWrap(True) 562 self.empty.setStyleSheet("color: gray;") 563 564 layout = QVBoxLayout(self) 565 layout.setContentsMargins(10, 8, 10, 8) 566 layout.addLayout(header) 567 layout.addWidget(scroll, 1) 568 layout.addWidget(self.empty, 1) 569 self._scroll = scroll 570 self.set_configurations([], None) 571 572 def refresh_palette(self): 573 """The theme changed: the cards drawn again in its colors (the same 574 configurations: the build folders aren't read again).""" 575 self.set_configurations(self.configs, self.project_dir) 576 577 def set_configurations(self, configs: list[Configuration], project_dir: Path | None): 578 self.configs, self.project_dir = configs, project_dir 579 while self.cards_layout.count(): 580 item = self.cards_layout.takeAt(0) 581 if item.widget(): 582 # Hide now: deleteLater only happens on a later event-loop pass. 583 item.widget().hide() 584 item.widget().setParent(None) 585 item.widget().deleteLater() 586 pal = PALETTES["light" if app_is_light() else "dark"] 587 for config in configs: 588 card = ConfigCard(config, pal) 589 card.run_requested.connect(self.run_requested) 590 card.run_in_terminal_requested.connect(self.run_in_terminal_requested) 591 card.debug_requested.connect(self.debug_requested) 592 card.valgrind_requested.connect(self.valgrind_requested) 593 card.reveal_requested.connect(self.reveal_requested) 594 card.delete_requested.connect(lambda c: self.delete_requested.emit([c])) 595 card.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Maximum) 596 self.cards_layout.addWidget(card) 597 self.cards_layout.addStretch(1) 598 has_current = any(c.current for c in configs) 599 total = sum(c.size for c in configs) 600 folders = sum(len(c.folders) for c in configs) 601 if project_dir is None: 602 message = "Open an fpm.toml to see its build outputs." 603 elif not configs: 604 message = "Nothing has been built yet (no build/<compiler>_<hash> folders)." 605 else: 606 message = "" 607 summary = (f"<b>{len(configs)} configuration{'s' * (len(configs) != 1)}</b> · " 608 f"{folders} folders · {human_size(total)}") if configs else "" 609 if configs and not has_current: 610 summary += ("<br><span style='color:gray'>The settings of the last build aren't " 611 "recorded yet (their folders were built outside the app) — Clean and " 612 "build again to record them.</span>") 613 self.summary.setText(summary) 614 self.prune_button.setEnabled(len(configs) > 1 and has_current) 615 self.prune_button.setToolTip("" if has_current or not configs else 616 "No configuration is marked current yet") 617 self.empty.setText(message) 618 self.empty.setVisible(bool(message)) 619 self._scroll.setVisible(not message)
The "Build outputs" tab.
534 def __init__(self, parent=None): 535 super().__init__(parent) 536 self.configs: list[Configuration] = [] 537 self.project_dir: Path | None = None 538 self.summary = QLabel() 539 refresh = QToolButton() 540 refresh.setText("Refresh") 541 refresh.clicked.connect(self.refresh_requested) 542 self.prune_button = QToolButton() 543 self.prune_button.setText("Delete All Except Current…") 544 self.prune_button.clicked.connect( 545 lambda: self.delete_requested.emit([c for c in self.configs if not c.current])) 546 header = QHBoxLayout() 547 header.addWidget(self.summary, 1) 548 header.addWidget(self.prune_button) 549 header.addWidget(refresh) 550 551 self.cards = QWidget() 552 self.cards_layout = QVBoxLayout(self.cards) 553 self.cards_layout.setContentsMargins(0, 0, 0, 0) 554 self.cards_layout.setSpacing(10) 555 scroll = QScrollArea() 556 scroll.setWidgetResizable(True) 557 scroll.setFrameShape(QFrame.NoFrame) 558 scroll.setWidget(self.cards) 559 self.empty = QLabel() 560 self.empty.setAlignment(Qt.AlignCenter) 561 self.empty.setWordWrap(True) 562 self.empty.setStyleSheet("color: gray;") 563 564 layout = QVBoxLayout(self) 565 layout.setContentsMargins(10, 8, 10, 8) 566 layout.addLayout(header) 567 layout.addWidget(scroll, 1) 568 layout.addWidget(self.empty, 1) 569 self._scroll = scroll 570 self.set_configurations([], None)
__init__(self, /, parent: PySide6.QtWidgets.QWidget | None = None, f: PySide6.QtCore.Qt.WindowType = Default(Qt.WindowFlags), *, modal: bool | None = None, windowModality: PySide6.QtCore.Qt.WindowModality | None = None, enabled: bool | None = None, geometry: PySide6.QtCore.QRect | None = None, frameGeometry: PySide6.QtCore.QRect | None = None, normalGeometry: PySide6.QtCore.QRect | None = None, x: int | None = None, y: int | None = None, pos: PySide6.QtCore.QPoint | None = None, frameSize: PySide6.QtCore.QSize | None = None, size: PySide6.QtCore.QSize | None = None, width: int | None = None, height: int | None = None, rect: PySide6.QtCore.QRect | None = None, childrenRect: PySide6.QtCore.QRect | None = None, childrenRegion: PySide6.QtGui.QRegion | None = None, sizePolicy: PySide6.QtWidgets.QSizePolicy | None = None, minimumSize: PySide6.QtCore.QSize | None = None, maximumSize: PySide6.QtCore.QSize | None = None, minimumWidth: int | None = None, minimumHeight: int | None = None, maximumWidth: int | None = None, maximumHeight: int | None = None, sizeIncrement: PySide6.QtCore.QSize | None = None, baseSize: PySide6.QtCore.QSize | None = None, palette: PySide6.QtGui.QPalette | None = None, font: PySide6.QtGui.QFont | None = None, cursor: PySide6.QtGui.QCursor | None = None, mouseTracking: bool | None = None, tabletTracking: bool | None = None, isActiveWindow: bool | None = None, focusPolicy: PySide6.QtCore.Qt.FocusPolicy | None = None, focus: bool | None = None, contextMenuPolicy: PySide6.QtCore.Qt.ContextMenuPolicy | None = None, updatesEnabled: bool | None = None, visible: bool | None = None, minimized: bool | None = None, maximized: bool | None = None, fullScreen: bool | None = None, sizeHint: PySide6.QtCore.QSize | None = None, minimumSizeHint: PySide6.QtCore.QSize | None = None, acceptDrops: bool | None = None, windowTitle: str | None = None, windowIcon: PySide6.QtGui.QIcon | None = None, windowIconText: str | None = None, windowOpacity: float | None = None, windowModified: bool | None = None, toolTip: str | None = None, toolTipDuration: int | None = None, statusTip: str | None = None, whatsThis: str | None = None, accessibleName: str | None = None, accessibleDescription: str | None = None, accessibleIdentifier: str | None = None, layoutDirection: PySide6.QtCore.Qt.LayoutDirection | None = None, autoFillBackground: bool | None = None, styleSheet: str | None = None, locale: PySide6.QtCore.QLocale | None = None, windowFilePath: str | None = None, inputMethodHints: PySide6.QtCore.Qt.InputMethodHint | None = None) -> None
Initialize self. See help(type(self)) for accurate signature.
572 def refresh_palette(self): 573 """The theme changed: the cards drawn again in its colors (the same 574 configurations: the build folders aren't read again).""" 575 self.set_configurations(self.configs, self.project_dir)
The theme changed: the cards drawn again in its colors (the same configurations: the build folders aren't read again).
577 def set_configurations(self, configs: list[Configuration], project_dir: Path | None): 578 self.configs, self.project_dir = configs, project_dir 579 while self.cards_layout.count(): 580 item = self.cards_layout.takeAt(0) 581 if item.widget(): 582 # Hide now: deleteLater only happens on a later event-loop pass. 583 item.widget().hide() 584 item.widget().setParent(None) 585 item.widget().deleteLater() 586 pal = PALETTES["light" if app_is_light() else "dark"] 587 for config in configs: 588 card = ConfigCard(config, pal) 589 card.run_requested.connect(self.run_requested) 590 card.run_in_terminal_requested.connect(self.run_in_terminal_requested) 591 card.debug_requested.connect(self.debug_requested) 592 card.valgrind_requested.connect(self.valgrind_requested) 593 card.reveal_requested.connect(self.reveal_requested) 594 card.delete_requested.connect(lambda c: self.delete_requested.emit([c])) 595 card.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Maximum) 596 self.cards_layout.addWidget(card) 597 self.cards_layout.addStretch(1) 598 has_current = any(c.current for c in configs) 599 total = sum(c.size for c in configs) 600 folders = sum(len(c.folders) for c in configs) 601 if project_dir is None: 602 message = "Open an fpm.toml to see its build outputs." 603 elif not configs: 604 message = "Nothing has been built yet (no build/<compiler>_<hash> folders)." 605 else: 606 message = "" 607 summary = (f"<b>{len(configs)} configuration{'s' * (len(configs) != 1)}</b> · " 608 f"{folders} folders · {human_size(total)}") if configs else "" 609 if configs and not has_current: 610 summary += ("<br><span style='color:gray'>The settings of the last build aren't " 611 "recorded yet (their folders were built outside the app) — Clean and " 612 "build again to record them.</span>") 613 self.summary.setText(summary) 614 self.prune_button.setEnabled(len(configs) > 1 and has_current) 615 self.prune_button.setToolTip("" if has_current or not configs else 616 "No configuration is marked current yet") 617 self.empty.setText(message) 618 self.empty.setVisible(bool(message)) 619 self._scroll.setVisible(not message)