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)
BUILD_DIR_RE = re.compile('^(?P<compiler>.+)_(?P<hash>[0-9A-F]{16})$')
PROGRAM_KINDS = {'app': 'program', 'test': 'test', 'example': 'example'}
GROUP_GAP_SECONDS = 120
@dataclass
class BuildFolder:
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"
BuildFolder( path: pathlib.Path, compiler: str, newest: float = 0.0, size: int = 0, programs: list[tuple[str, pathlib.Path]] = <factory>, libraries: list[pathlib.Path] = <factory>, modules: int = 0, objects: int = 0)
path: pathlib.Path
compiler: str
newest: float = 0.0
size: int = 0
programs: list[tuple[str, pathlib.Path]]
libraries: list[pathlib.Path]
modules: int = 0
objects: int = 0
name: str
66    @property
67    def name(self) -> str:
68        return self.path.name
role: str
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"
@dataclass
class Configuration:
 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)
Configuration( label: str, folders: list[BuildFolder], recorded: bool, flags: str = '', built: float = 0.0, used: float = 0.0, current: bool = False)
label: str
folders: list[BuildFolder]
recorded: bool
flags: str = ''
built: float = 0.0
used: float = 0.0
current: bool = False
size: int
91    @property
92    def size(self) -> int:
93        return sum(f.size for f in self.folders)
programs: list[tuple[str, pathlib.Path]]
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))
libraries: list[pathlib.Path]
100    @property
101    def libraries(self) -> list[Path]:
102        return [lib for f in self.folders for lib in f.libraries]
modules: int
104    @property
105    def modules(self) -> int:
106        return sum(f.modules for f in self.folders)
objects: int
108    @property
109    def objects(self) -> int:
110        return sum(f.objects for f in self.folders)
def build_folders(project_dir: pathlib.Path) -> list[pathlib.Path]:
114def build_folders(project_dir: Path) -> list[Path]:
115    build = project_dir / "build"
116    if not build.is_dir():
117        return []
118    return sorted(p for p in build.iterdir() if p.is_dir() and BUILD_DIR_RE.match(p.name))
def scan_folder(path: pathlib.Path) -> BuildFolder:
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
def snapshot(project_dir: pathlib.Path) -> dict[str, float]:
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.

def changed_folders(before: dict[str, float], after: dict[str, float]) -> list[str]:
157def changed_folders(before: dict[str, float], after: dict[str, float]) -> list[str]:
158    return sorted(name for name, newest in after.items() if newest > before.get(name, -1))
def read_compile_commands(project_dir: pathlib.Path) -> tuple[dict[str, list[str]], str | None]:
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.

def format_command(args: list[str]) -> str:
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.

class ConfigRecords:
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}, ...].

ConfigRecords(settings, key: str)
209    def __init__(self, settings, key: str):
210        self.settings, self.key = settings, key
def load(self) -> list[dict]:
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 []
def save(self, records: list[dict]):
219    def save(self, records: list[dict]):
220        self.settings.setValue(self.key, json.dumps(records))
def last_used(self) -> tuple[str, str] | None:
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.

def record_build(self, label: str, flags: str, folders: list[str]):
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.

def mark_used(self, label: str, flags: str):
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.

def forget(self, folders: list[str]):
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"]])
def configurations( project_dir: pathlib.Path, records: list[dict], last_used: tuple[str, str] | None = None) -> list[Configuration]:
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.

def human_size(n: int) -> str:
316def human_size(n: int) -> str:
317    for unit in ("B", "KB", "MB", "GB"):
318        if n < 1024 or unit == "GB":
319            return f"{n:.0f} {unit}" if unit == "B" else f"{n:.1f} {unit}"
320        n /= 1024
321    return f"{n} B"
def human_age(t: float) -> str:
324def human_age(t: float) -> str:
325    seconds = max(0, time.time() - t)
326    if seconds < 60:
327        return "just now"
328    for unit, size in (("day", 86400), ("hour", 3600), ("minute", 60)):
329        if seconds >= size:
330            n = int(seconds // size)
331            return f"{n} {unit}{'s' * (n != 1)} ago"
332    return ""
def delete_folders(paths: list[pathlib.Path]) -> list[str]:
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).

NO_VALGRIND = "Valgrind wasn't found (on the PATH). It checks a program's memory use (leaks, invalid reads and writes) on Linux: install it there (the project's pixi environment has it on Linux), or with the system's package manager."
def valgrind_found() -> bool:
352def valgrind_found() -> bool:
353    return shutil.which("valgrind") is not None
VALGRIND_SUMMARY_RE = re.compile('^==\\d+== ERROR SUMMARY: ([\\d,]+) errors?', re.MULTILINE)
def valgrind_errors(output: str) -> int | None:
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).

def executable_name(path: pathlib.Path) -> str:
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").

class ConfigCard(PySide6.QtWidgets.QFrame):
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

ConfigCard(config: Configuration, pal: dict)
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.

def run_requested(unknown):

The type of the None singleton.

def run_in_terminal_requested(unknown):

The type of the None singleton.

def debug_requested(unknown):

The type of the None singleton.

def valgrind_requested(unknown):

The type of the None singleton.

def reveal_requested(unknown):

The type of the None singleton.

def delete_requested(unknown):

The type of the None singleton.

config
rows: dict[PySide6.QtWidgets.QWidget, tuple[str, pathlib.Path]]
def context_menu(self, pos) -> PySide6.QtWidgets.QMenu:
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

The right-click menu at pos: for a program or library when it's on one, else for the whole configuration.

def contextMenuEvent(self, event):
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

staticMetaObject = PySide6.QtCore.QMetaObject("ConfigCard" inherits "QFrame": Methods: #33 type=Signal, signature=run_requested(QString), parameters=QString #34 type=Signal, signature=run_in_terminal_requested(QString), parameters=QString #35 type=Signal, signature=debug_requested(QString,QString), parameters=QString, QString #36 type=Signal, signature=valgrind_requested(QString,QString), parameters=QString, QString #37 type=Signal, signature=reveal_requested(QString), parameters=QString #38 type=Signal, signature=delete_requested(PyObject), parameters=PyObject )
class BuildOutputsView(PySide6.QtWidgets.QWidget):
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.

BuildOutputsView(parent=None)
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.

def run_requested(unknown):

The type of the None singleton.

def run_in_terminal_requested(unknown):

The type of the None singleton.

def debug_requested(unknown):

The type of the None singleton.

def valgrind_requested(unknown):

The type of the None singleton.

def reveal_requested(unknown):

The type of the None singleton.

def delete_requested(unknown):

The type of the None singleton.

def refresh_requested(unknown):

The type of the None singleton.

configs: list[Configuration]
project_dir: pathlib.Path | None
summary
prune_button
cards
cards_layout
empty
def refresh_palette(self):
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).

def set_configurations( self, configs: list[Configuration], project_dir: pathlib.Path | None):
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)
staticMetaObject = PySide6.QtCore.QMetaObject("BuildOutputsView" inherits "QWidget": Methods: #33 type=Signal, signature=run_requested(QString), parameters=QString #34 type=Signal, signature=run_in_terminal_requested(QString), parameters=QString #35 type=Signal, signature=debug_requested(QString,QString), parameters=QString, QString #36 type=Signal, signature=valgrind_requested(QString,QString), parameters=QString, QString #37 type=Signal, signature=reveal_requested(QString), parameters=QString #38 type=Signal, signature=delete_requested(PyObject), parameters=PyObject #39 type=Signal, signature=refresh_requested() )