vibefpmgui.window.running

Running fpm: the Build options, the commands and the features.

The output goes to vibefpmgui.window.output; Test and Run's steps are in vibefpmgui.window.testing.

  1"""Running fpm: the Build options, the commands and the features.
  2
  3The output goes to `vibefpmgui.window.output`; Test and Run's steps are in
  4`vibefpmgui.window.testing`.
  5"""
  6
  7from __future__ import annotations
  8
  9import html
 10import os
 11import shutil
 12import tomllib
 13
 14from qtpy.QtCore import QProcess, QProcessEnvironment, QSize, Qt
 15from qtpy.QtGui import QAction, QKeySequence, QPalette
 16from qtpy.QtWidgets import (
 17    QApplication,
 18    QCheckBox,
 19    QComboBox,
 20    QFileDialog,
 21    QFormLayout,
 22    QFrame,
 23    QHBoxLayout,
 24    QLineEdit,
 25    QMessageBox,
 26    QSpinBox,
 27    QToolBar,
 28    QToolButton,
 29    QWidget,
 30)
 31
 32from .. import ansi, dep_updates
 33from .. import build_outputs as bo
 34from .. import coverage as cov
 35from .. import manifest as m
 36from ..compat import join_args, quote_arg, split_args
 37from ..fpm_args import split_runner, with_link_flag
 38from ..icons import make_icon
 39from ..processes import kill_process_tree
 40from ..widgets import MenuButton, StayOpenMenu
 41
 42# (label, fpm sub-command, uses build options, refresh graph afterwards, icon, shortcut, description)
 43COMMANDS = [
 44    ("Build", ["build"], True, True, "build", "Ctrl+B", "Compile the project"),
 45    ("Build Tests", ["build", "--tests"], True, True, "build-tests", "Ctrl+Shift+B",
 46     "Compile the project and all its tests, without running them"),
 47    ("Run", ["run"], True, True, "run", "Ctrl+R", "Build and run the program(s)"),
 48    ("Test", ["test"], True, True, "test", "Ctrl+U", "Build and run the tests"),
 49    ("Update deps", ["update"], False, True, "update", "", "Fetch or update the dependencies"),
 50    ("Clean", ["clean", "--skip"], False, False, "clean", "",
 51     "Delete the build output, keeping downloaded dependencies"),
 52    ("Clean all", ["clean", "--all"], False, True, "clean-all", "",
 53     "Delete the build output including downloaded dependencies"),
 54]
 55
 56
 57STOP_COLOR = "#e03131"
 58
 59
 60BUILTIN_PROFILES = ["(default)", "debug", "release"]
 61
 62
 63class RunningMixin:
 64    """Running fpm (a part of `vibefpmgui.main_window.MainWindow`)."""
 65
 66    def _build_options_panel(self):
 67        """The Build options panel's fields (each one's tooltip: the fpm flag it becomes)."""
 68        options = QWidget()  # (in the "Build options" panel)
 69        form = QFormLayout(options)
 70        # macOS defaults to fields staying at their size hint; let them widen with the panel.
 71        form.setFieldGrowthPolicy(QFormLayout.AllNonFixedFieldsGrow)
 72        self.profile_combo = QComboBox()
 73        self.profile_combo.addItems(BUILTIN_PROFILES)
 74        self._build_config_row(form)  # (see vibefpmgui.window.configs)
 75        form.addRow("Profile:", self.profile_combo)
 76        self.features_button = MenuButton()  # (its menu: see _build_features_menu)
 77        form.addRow("Features:", self.features_button)
 78        self.compiler_edit = QLineEdit()
 79        env_fc = os.environ.get("FPM_FC", "").strip()
 80        self.compiler_edit.setPlaceholderText(f"{env_fc} (from FPM_FC)" if env_fc else "gfortran (default)")
 81        form.addRow("Compiler:", self.compiler_edit)
 82        self.flag_edit = QLineEdit()
 83        self.flag_edit.setPlaceholderText("e.g. -O3 -fcheck=all")
 84        form.addRow("Fortran flags:", self.flag_edit)
 85        self.target_edit = QLineEdit()
 86        self.target_edit.setPlaceholderText("all targets")
 87        form.addRow("Target:", self.target_edit)
 88        self.extra_edit = QLineEdit()
 89        self.extra_edit.setPlaceholderText("extra fpm arguments")
 90        form.addRow("Extra args:", self.extra_edit)
 91        self.runner_args_edit = QLineEdit()
 92        self.runner_args_edit.setPlaceholderText("arguments passed to the program")
 93        form.addRow("Program args:", self.runner_args_edit)
 94        self.test_jobs_spin = QSpinBox()
 95        self.test_jobs_spin.setRange(1, 256)
 96        self.test_jobs_spin.setSpecialValueText("off (one at a time)")
 97        self.test_jobs_spin.setSuffix(" at a time")
 98        form.addRow("Parallel tests:", self.test_jobs_spin)
 99        self.coverage_check = QCheckBox("Coverage")
100        form.addRow(self.coverage_check)
101        self.verbose_check = QCheckBox("Verbose")
102        form.addRow(self.verbose_check)
103        fpm_row = QHBoxLayout()
104        self.fpm_edit = QLineEdit()
105        self.fpm_edit.setPlaceholderText(shutil.which("fpm") or "fpm")
106        fpm_browse = QToolButton()
107        fpm_browse.setText("…")
108        fpm_browse.clicked.connect(self._browse_fpm)
109        fpm_row.addWidget(self.fpm_edit)
110        fpm_row.addWidget(fpm_browse)
111        # A line above it: the fpm executable isn't part of a build configuration (a path on this computer).
112        divider = QFrame()
113        divider.setFrameShape(QFrame.HLine)
114        divider.setFrameShadow(QFrame.Sunken)
115        form.addRow(divider)
116        form.addRow("fpm executable:", fpm_row)
117
118        # Hovering an option (or its label) shows the fpm flag it becomes.
119        for widget, flag, text in [
120            (self.profile_combo, "--profile PROF",
121             ("Build profile: debug or release. (default) passes none: fpm then uses no profile's flags "
122              "(no -g; Start Debugging uses debug).")),
123            (self.features_button, "--features LIST",
124             "Features to enable (the project's fpm.toml declares them). Can't be combined with a profile."),
125            (self.compiler_edit, "--compiler NAME", "Fortran compiler to use. fpm uses gfortran by default."),
126            (self.flag_edit, "--flag FFLAGS", "Extra Fortran compiler flags."),
127            (self.target_edit, "fpm run|test NAME…",
128             "Names of the programs or tests to run (wildcards allowed). Used by Run and Test."),
129            (self.extra_edit, "(added as typed)",
130             "Any other fpm options, e.g. --runner \"mpirun -np 4\" or --c-compiler gcc."),
131            (self.runner_args_edit, "-- ARGS",
132             "Arguments passed to the program or tests by Run and Test."),
133            (self.test_jobs_spin, "(not a flag)",
134             (f"Run this many tests at once (this computer has {os.cpu_count() or 1} cores). fpm builds "
135              "them; the app runs them, each with its own output. Off: fpm runs them one after another.")),
136            (self.coverage_check, "--flag --coverage --link-flag --coverage",
137             ("Build with gfortran's coverage counting (a build of its own): after Test, the Coverage panel "
138              "and the editor's gutter show which lines the tests ran.")),
139            (self.verbose_check, "--verbose", "Print more detail about what fpm is doing."),
140            (self.fpm_edit, "(not a flag)",
141             ("The fpm program to run. Leave empty to use the fpm on your PATH. Not part of a build "
142              "configuration: it's kept for this computer.")),
143        ]:
144            tip = f"<b><code>{html.escape(flag)}</code></b><br>{html.escape(text)}"
145            widget.setToolTip(tip)
146            label = form.labelForField(widget) or form.labelForField(fpm_row)
147            if label is not None and widget is not self.verbose_check:
148                label.setToolTip(tip)
149        self._watch_option_fields()
150
151        return options
152
153    def _build_commands(self):
154        """The fpm commands as actions, shown in a toolbar and a Commands menu."""
155        self.command_icons: dict[QAction, str] = {}
156        if hasattr(self, "palette_action"):  # (View → Command Palette…: its icon, redrawn with the theme's colors too)
157            self.command_icons[self.palette_action] = "palette"
158        if hasattr(self, "go_to_file_action"):  # (Edit → Go To → Go to File…: the same)
159            self.command_icons[self.go_to_file_action] = "find-file"
160
161        def make(label, icon, shortcut, description, slot):
162            action = QAction(label, self)
163            if shortcut:
164                action.setShortcut(QKeySequence(shortcut))
165                keys = QKeySequence(shortcut).toString(QKeySequence.NativeText)
166                action.setToolTip(f"{label} ({keys}) — {description}")
167            else:
168                action.setToolTip(f"{label} — {description}")
169            action.setStatusTip(description)
170            action.triggered.connect(slot)
171            self.command_icons[action] = icon
172            return action
173
174        actions = {}
175        for label, args, uses_options, refresh, icon, shortcut, description in COMMANDS:
176            if args == ["test"]:
177                slot = self.run_tests
178            elif args == ["run"]:
179                slot = self.run_programs
180            else:
181                slot = lambda _=False, a=args, o=uses_options, r=refresh: self.run_fpm(a, o, r)
182            actions[icon] = make(label, icon, shortcut, description, slot)
183            self.command_actions.append(actions[icon])
184        self.stop_action = make("Stop", "stop", "Ctrl+.", "Stop the running fpm command", self.stop)
185        self.stop_action.setEnabled(False)
186
187        groups = [[actions["build"], actions["build-tests"], actions["run"], actions["test"], self.stop_action],
188                  [actions["update"]],
189                  [actions["clean"], actions["clean-all"]]]
190        toolbar = QToolBar("Commands")
191        toolbar.setObjectName("CommandsToolbar")
192        toolbar.setMovable(False)
193        toolbar.setFloatable(False)
194        toolbar.setToolButtonStyle(Qt.ToolButtonTextUnderIcon)
195        toolbar.setIconSize(QSize(22, 22))
196        toolbar.toggleViewAction().setEnabled(False)  # don't let it be hidden by accident
197        menu = self.menuBar().addMenu("&Commands")
198        for i, group in enumerate(groups):
199            if i:
200                toolbar.addSeparator()
201                menu.addSeparator()
202            toolbar.addActions(group)
203            menu.addActions(group)
204        menu.addSeparator()
205        self.check_updates_action = menu.addAction("Check for Dependency Updates…")
206        self.check_updates_action.setStatusTip(
207            "Ask each git dependency's repository for newer tags and commits")
208        self.check_updates_action.triggered.connect(self.check_dependency_updates)
209        self.addToolBar(Qt.TopToolBarArea, toolbar)
210        self.setUnifiedTitleAndToolBarOnMac(True)
211        self.command_toolbar = toolbar
212        # Right-click: the toolbar's look (View → Toolbar's choices; Qt's own menu would list the panels).
213        toolbar.setContextMenuPolicy(Qt.CustomContextMenu)
214        toolbar.customContextMenuRequested.connect(
215            lambda pos: self.toolbar_menu.popup(toolbar.mapToGlobal(pos)))
216        self._update_command_icons()
217
218    def _update_command_icons(self):
219        """(Re)draw the toolbar icons in the current text color."""
220        text = QApplication.palette().color(QPalette.WindowText)
221        for action, name in self.command_icons.items():
222            action.setIcon(make_icon(name, STOP_COLOR if name == "stop" else text))
223        # The ">>" button shown when the window is too narrow for every
224        # button. Qt 6.11 draws its default arrow with a macOS symbol image,
225        # which crashes in AppKit on macOS 27, so give it our own icon.
226        extension = self.command_toolbar.findChild(QToolButton, "qt_toolbar_ext_button")
227        if extension is not None:
228            extension.setIcon(make_icon("more", text))
229        hover = "rgba(128,128,128,45)"
230        self.command_toolbar.setStyleSheet(f"""
231            QToolBar#CommandsToolbar {{ border: none; spacing: 2px; padding: 2px 6px; }}
232            QToolBar#CommandsToolbar QToolButton {{ border: none; border-radius: 7px;
233                padding: 4px 10px; }}
234            QToolBar#CommandsToolbar QToolButton:hover {{ background: {hover}; }}
235            QToolBar#CommandsToolbar QToolButton:pressed {{ background: rgba(128,128,128,80); }}
236            QToolBar#CommandsToolbar QToolButton#qt_toolbar_ext_button {{ padding: 0px; }}
237        """)
238
239    def busy(self) -> bool:
240        """A command is running (fpm, a program, or tests run in parallel)."""
241        return self.process is not None or self.parallel_tests is not None
242
243    def _set_project_enabled(self, enabled: bool):
244        running = self.busy()
245        for action in self.command_actions:
246            action.setEnabled(enabled and not running)
247
248    def effective_compiler(self) -> tuple[str, str]:
249        """(compiler, where the choice comes from), the way fpm picks it:
250        --compiler (the Compiler field), else $FPM_FC, else gfortran."""
251        typed = self.compiler_edit.text().strip()
252        if typed:
253            return typed, "Compiler field in Build options"
254        env = os.environ.get("FPM_FC", "").strip()
255        if env:
256            return env, "FPM_FC environment variable"
257        return "gfortran", "fpm's default"
258
259    def _browse_fpm(self):
260        filename, _ = QFileDialog.getOpenFileName(self, "Select fpm executable")
261        if filename:
262            self.fpm_edit.setText(filename)
263
264    def _fpm_executable(self) -> str:
265        return self.fpm_edit.text().strip() or shutil.which("fpm") or "fpm"
266
267    def build_arguments(self, sub: list[str], uses_options: bool,
268                        program_args: bool = True, targets: list[str] | None = None) -> list[str]:
269        """fpm's arguments for ``sub``; ``targets``: these programs or tests
270        (instead of the Target field's)."""
271        args = list(sub)
272        if uses_options:
273            if self.selected_features:  # fpm rejects --features together with --profile
274                args += ["--features", ",".join(self.selected_features)]
275            elif self.profile_combo.currentIndex() > 0:
276                args += ["--profile", self.profile_combo.currentText()]
277            if self.compiler_edit.text().strip():
278                args += ["--compiler", self.compiler_edit.text().strip()]
279            coverage = self.coverage_check.isChecked() and sub[0] in ("build", "run", "test", "install")
280            flags = " ".join(f for f in (self.flag_edit.text().strip(), cov.FLAG if coverage else "") if f)
281            if flags:
282                args += ["--flag", flags]
283            if self.verbose_check.isChecked():
284                args.append("--verbose")
285            names = split_args(self.target_edit.text()) if targets is None else targets
286            if sub[0] in ("run", "test") and names:
287                args += names
288            extra = split_args(self.extra_edit.text())
289            if sub[0] in ("test", "run"):
290                extra, _ = split_runner(extra)  # (the user's --runner: run inside ours, see _start_marked)
291            if coverage:  # (linked with it too: the user's own --link-flag gets it added)
292                extra = with_link_flag(extra, cov.FLAG)
293            args += extra
294            if program_args and sub[0] in ("run", "test") and self.runner_args_edit.text().strip():
295                args += ["--"] + split_args(self.runner_args_edit.text())
296        elif sub[0] == "update" and self.verbose_check.isChecked():
297            args.append("--verbose")
298        return args
299
300    def _build_features_menu(self):
301        """The Features button's menu in Build options (filled from the project's manifest)."""
302        self.features_menu = StayOpenMenu(self)
303        self.features_button.setMenu(self.features_menu)
304        self.project_features: dict[str, list[str]] = {}
305        self.selected_features: list[str] = []
306        self._wanted_profile = "(default)"
307        self._rebuild_features_menu()
308
309    def _load_features_and_profiles(self):
310        """Read [features] and [profiles] from the project's fpm.toml."""
311        try:
312            data = m.load_toml(self.manifest_path)
313        except (OSError, tomllib.TOMLDecodeError):
314            data = {}
315        self.project_features = m.project_features(data)
316        profiles = m.project_profiles(data)
317
318        # Profile list: built-in ones plus the project's feature profiles.
319        current = self.profile_combo.currentText() if self.profile_combo.count() else self._wanted_profile
320        self.profile_combo.blockSignals(True)
321        self.profile_combo.clear()
322        self.profile_combo.addItems(BUILTIN_PROFILES)
323        for name, features in profiles.items():
324            if name not in BUILTIN_PROFILES:
325                self.profile_combo.addItem(name)
326                index = self.profile_combo.count() - 1
327                self.profile_combo.setItemData(
328                    index, f"Feature profile: {', '.join(features) or '(no features)'}", Qt.ToolTipRole)
329        if self.profile_combo.findText(current) >= 0:
330            self.profile_combo.setCurrentText(current)
331        self.profile_combo.blockSignals(False)
332
333        if not self.config_shown():  # (a configuration's own features stay; see vibefpmgui.window.configs)
334            saved = self._saved_free_options().features
335            self.selected_features = [f for f in saved if f in self.project_features]
336        self._rebuild_features_menu()
337
338    def _rebuild_features_menu(self):
339        menu = self.features_menu
340        menu.clear()
341        if not self.project_features:
342            none = menu.addAction("No features defined in fpm.toml")
343            none.setEnabled(False)
344        self.feature_actions: dict[str, QAction] = {}
345        for name, props in self.project_features.items():
346            action = menu.addAction(name)
347            action.setCheckable(True)
348            action.setChecked(name in self.selected_features)
349            self.feature_actions[name] = action
350            shown = ", ".join(props[:8]) + (" …" if len(props) > 8 else "")
351            action.setToolTip(f"Sets: {shown}" if props else name)
352            action.setStatusTip(f"Feature {name} — sets {shown}" if props else f"Feature {name}")
353            action.toggled.connect(lambda on, n=name: self._toggle_feature(n, on))
354        menu.setToolTipsVisible(True)
355        self.clear_features_action = None
356        if self.project_features:
357            menu.addSeparator()
358            self.clear_features_action = menu.addAction("Clear all features")
359            self.clear_features_action.triggered.connect(self._clear_features)
360        self._update_features_ui()
361
362    def _toggle_feature(self, name: str, on: bool):
363        if on and name not in self.selected_features:
364            # Keep the order they're declared in fpm.toml.
365            chosen = set(self.selected_features) | {name}
366            self.selected_features = [f for f in self.project_features if f in chosen]
367        elif not on and name in self.selected_features:
368            self.selected_features.remove(name)
369        self._save_features()
370        # Only update the existing actions: rebuilding the menu here would
371        # delete the action whose signal is being handled.
372        self._update_features_ui()
373
374    def _clear_features(self):
375        self.selected_features = []
376        self._save_features()
377        self._update_features_ui()
378
379    def _save_features(self):
380        """New configuration's features (not a saved configuration's, being edited)."""
381        if self.manifest_path and not self.config_shown():
382            self._keep_free_options(self.current_options())
383
384    def _update_features_ui(self):
385        """Show the selection, and disable Profile while features are on."""
386        locked = self.options_locked()  # (a saved configuration's: read-only)
387        for name, action in getattr(self, "feature_actions", {}).items():
388            if action.isChecked() != (name in self.selected_features):
389                action.blockSignals(True)
390                action.setChecked(name in self.selected_features)
391                action.blockSignals(False)
392            action.setEnabled(not locked)
393        if getattr(self, "clear_features_action", None) is not None:
394            self.clear_features_action.setEnabled(bool(self.selected_features) and not locked)
395        if self.selected_features:
396            shown = ", ".join(self.selected_features)
397        else:
398            shown = "none" if self.project_features else "none defined in fpm.toml"
399        self.features_button.set_full_text(shown)
400        self.features_button.setEnabled(bool(self.project_features) and not locked)
401        self.features_button.setToolTip(f"<b><code>--features LIST</code></b><br>{html.escape(shown)}<br>"
402                                        "Features to enable (the project's fpm.toml declares them). Can't be "
403                                        "combined with a profile.")
404        using = bool(self.selected_features)
405        self.profile_combo.setEnabled(not using and not locked)
406        self.profile_combo.setToolTip(
407            "<b><code>--profile PROF</code></b><br>"
408            + ("Disabled while features are selected: fpm can't combine --profile and --features."
409               if using else "Build profile: debug, release, or a feature profile from fpm.toml."))
410        self._options_changed()  # (the features are options too)
411
412    def _build_description(self) -> str:
413        compiler, _source = self.effective_compiler()
414        if self.selected_features:
415            return f"features: {', '.join(self.selected_features)} · {compiler}"
416        profile = self.profile_combo.currentText() if self.profile_combo.currentIndex() > 0 else "debug"
417        return f"{profile} · {compiler}"
418
419    def run_fpm(self, sub: list[str], uses_options: bool, refresh: bool,
420                phase: str | None = None, args: list[str] | None = None,
421                env_extra: dict[str, str] | None = None):
422        if not self.manifest_path or self.busy():
423            return
424        if args is None:
425            try:
426                args = self.build_arguments(sub, uses_options)
427            except ValueError as exc:  # unbalanced quotes in a text field
428                QMessageBox.warning(self, "Invalid arguments", str(exc))
429                return
430        program = self._fpm_executable()
431        self.process = QProcess(self)
432        self.process.setWorkingDirectory(str(self.manifest_path.parent))
433        self.process.setProcessChannelMode(QProcess.MergedChannels)
434        env = QProcessEnvironment.systemEnvironment()
435        env.insert("NO_COLOR", "1")
436        for key, value in (env_extra or {}).items():
437            env.insert(key, value)
438        self.process.setProcessEnvironment(env)
439        self.process.readyReadStandardOutput.connect(self._read_output)
440        self.process.finished.connect(self._finished)
441        self.process.errorOccurred.connect(self._process_error)
442        self._refresh_after = refresh
443        self._partial = ""
444        self._phase = phase
445        self._captured = []
446        self._held_blank = False
447        # Commands that compile get the per-file build grid. "fpm test --list"
448        # doesn't build; "fpm test" builds first, so its output goes to the
449        # build grid until the first test starts (see _test_line).
450        self._monitoring = sub[0] in ("build", "run", "test") and phase != "list"
451        self._test_building = phase in ("marked", "collect", "debug")  # fpm builds before running
452        self._process_label = "fpm"
453        self._revs_before = (dep_updates.read_cache_revs(self.manifest_path.parent)
454                             if sub[0] == "update" else None)
455        # Note which build folders exist now, to record which ones this
456        # build writes (see _record_build_folders).
457        self._folder_snapshot = (bo.snapshot(self.manifest_path.parent)
458                                 if self._monitoring else None)
459        if self._monitoring:
460            self.build_monitor.start(self.graph, self.manifest_path.parent, self._build_description())
461            if phase is None:
462                self.show_all_output()  # any file shown on its own is gone
463            # Show the build's progress (Test switches to its tab once the
464            # tests themselves start).
465            self.show_panel(self.build_monitor)
466
467        self._append(f"\n$ cd {quote_arg(str(self.manifest_path.parent))}\n"
468                     f"$ {join_args([program] + args)}\n", bold=True)
469        self.stop_action.setEnabled(True)
470        self._set_project_enabled(True)
471        self.statusBar().showMessage(f"Running fpm {' '.join(sub)}…")
472        self._esc_tail = ""
473        self.console_writer.reset()
474        self._begin_output()
475        self.process.start(program, args)
476
477    def stop(self):
478        if self.process is not None:
479            # (Remembered: the process may still end normally. Its children go first,
480            # and fpm, seeing its test program killed, can exit with its code before
481            # it's killed too: the run is the one stopped, not a failure.)
482            self._stop_requested = True
483            kill_process_tree(self.process)
484        if self.parallel_tests is not None:
485            self.parallel_tests.stop()
486
487    def _stop_and_wait(self, msecs: int):
488        if self.process is not None:
489            kill_process_tree(self.process)
490            self.process.waitForFinished(msecs)
491        if self.parallel_tests is not None:
492            self.parallel_tests.stop()
493            self.parallel_tests.wait(msecs)
494
495    def _read_output(self, final: bool = False):
496        """New output from the running command; ``final``: it has finished
497        (bytes of an unfinished character are shown as "�")."""
498        if self.process is None:
499            return
500        data = self._decoder.decode(bytes(self.process.readAllStandardOutput()), final=final)
501        if not data:
502            return
503        data, self._esc_tail = ansi.split_partial(self._esc_tail + data)
504        # fpm redraws progress lines with \r; keep only the final state of each line.
505        data = data.replace("\r\n", "\n")
506        data = "\n".join(line.rsplit("\r", 1)[-1] for line in data.split("\n"))
507        if self._phase in ("marked", "direct", "collect", "debug"):
508            # Line-buffered so the test markers can be taken out of the output.
509            lines = (self._partial + data).split("\n")
510            self._partial = lines.pop()
511            handle = self._line_handler()
512            self._append("".join(handle(line) for line in lines), colored=True)
513            return
514        self._append(data, colored=True)
515        plain = ansi.strip(data)  # color codes would get in the way of parsing
516        if self._phase in ("list", "valgrind"):
517            self._captured.append(plain)
518        if self._monitoring:
519            # Output can arrive mid-line; only parse complete lines.
520            lines = (self._partial + plain).split("\n")
521            self._partial = lines.pop()
522            for line in lines:
523                self.build_monitor.feed_line(line)
524
525    def _line_handler(self):
526        """What reads each line of a run's output, per phase."""
527        return {"collect": self._collect_line, "debug": self._debug_line}.get(self._phase, self._test_line)
528
529    def _build_part_done(self):
530        """The build part of "fpm test" is over: show the tests."""
531        self._test_building = False
532        if self._monitoring:
533            self.build_monitor.finish(0)
534            self._update_tree_marks()
535            self._monitoring = False
536        self._marker_monitor().build_finished()
537        self.show_panel(self._marker_monitor())
538
539    def _flush_monitor(self, exit_code: int, stopped: bool):
540        if self._phase in ("marked", "direct", "collect", "debug"):
541            if self._partial:
542                handle = self._line_handler()
543                self._append(handle(self._partial), colored=True)
544            if self._held_blank:
545                self._append("\n")
546            self._partial = ""
547            self._held_blank = False
548        if self._monitoring:
549            if self._partial:
550                self.build_monitor.feed_line(self._partial)
551            self._partial = ""
552            self.build_monitor.finish(exit_code, stopped)
553            self._update_tree_marks()
554            self._monitoring = False
555
556    def _finished(self, code: int, status):
557        self._read_output(final=True)
558        crashed = status == QProcess.CrashExit or self._stop_requested  # (stopped)
559        self._flush_monitor(code, crashed)
560        self._end_output()
561        if crashed:
562            self._append("\n[process was stopped]\n", bold=True)
563        else:
564            self._append(f"\n[{self._process_label} exited with code {code}]\n", bold=True)
565        self._record_build_folders()
566        phase, self._phase = self._phase, None
567        self._cleanup_process()
568        self.statusBar().showMessage("Stopped" if crashed else
569                                     ("Finished successfully" if code == 0 else f"Failed (exit code {code})"))
570        self._report_dependency_changes()  # after the generic message, so it shows
571        if phase == "list":
572            self._after_listing(code, crashed)
573        elif phase in ("marked", "direct"):
574            self._after_marked(code, crashed)
575        elif phase == "collect":
576            self._after_collect(code, crashed)
577        elif phase == "debug":
578            self._after_debug_build(code, crashed)
579        elif phase == "valgrind":
580            self._after_valgrind(code, crashed)
581
582    def _process_error(self, error):
583        if error == QProcess.FailedToStart:
584            if getattr(self, "_process_label", "fpm") == "fpm":
585                self._append(f"\nCould not start '{self._fpm_executable()}'. "
586                             "Set the fpm executable in Build options.\n", bold=True)
587            else:
588                self._append(f"\nCould not start '{self._process_label}'.\n", bold=True)
589            self.statusBar().showMessage(f"{getattr(self, '_process_label', 'fpm')} failed to start")
590            self._flush_monitor(-1, True)
591            self._end_output()
592            if self._phase is not None:
593                self._marker_monitor().finish(-1, stopped=True)
594            self._phase = None
595            self._cleanup_process()
596
597    def _cleanup_process(self):
598        if self.process is None:
599            return
600        self.process.deleteLater()
601        self.process = None
602        self._stop_requested = False
603        self.stop_action.setEnabled(False)
604        self._set_project_enabled(self.graph is not None)
605        if self._refresh_after:
606            self.reload()
COMMANDS = [('Build', ['build'], True, True, 'build', 'Ctrl+B', 'Compile the project'), ('Build Tests', ['build', '--tests'], True, True, 'build-tests', 'Ctrl+Shift+B', 'Compile the project and all its tests, without running them'), ('Run', ['run'], True, True, 'run', 'Ctrl+R', 'Build and run the program(s)'), ('Test', ['test'], True, True, 'test', 'Ctrl+U', 'Build and run the tests'), ('Update deps', ['update'], False, True, 'update', '', 'Fetch or update the dependencies'), ('Clean', ['clean', '--skip'], False, False, 'clean', '', 'Delete the build output, keeping downloaded dependencies'), ('Clean all', ['clean', '--all'], False, True, 'clean-all', '', 'Delete the build output including downloaded dependencies')]
STOP_COLOR = '#e03131'
BUILTIN_PROFILES = ['(default)', 'debug', 'release']
class RunningMixin:
 64class RunningMixin:
 65    """Running fpm (a part of `vibefpmgui.main_window.MainWindow`)."""
 66
 67    def _build_options_panel(self):
 68        """The Build options panel's fields (each one's tooltip: the fpm flag it becomes)."""
 69        options = QWidget()  # (in the "Build options" panel)
 70        form = QFormLayout(options)
 71        # macOS defaults to fields staying at their size hint; let them widen with the panel.
 72        form.setFieldGrowthPolicy(QFormLayout.AllNonFixedFieldsGrow)
 73        self.profile_combo = QComboBox()
 74        self.profile_combo.addItems(BUILTIN_PROFILES)
 75        self._build_config_row(form)  # (see vibefpmgui.window.configs)
 76        form.addRow("Profile:", self.profile_combo)
 77        self.features_button = MenuButton()  # (its menu: see _build_features_menu)
 78        form.addRow("Features:", self.features_button)
 79        self.compiler_edit = QLineEdit()
 80        env_fc = os.environ.get("FPM_FC", "").strip()
 81        self.compiler_edit.setPlaceholderText(f"{env_fc} (from FPM_FC)" if env_fc else "gfortran (default)")
 82        form.addRow("Compiler:", self.compiler_edit)
 83        self.flag_edit = QLineEdit()
 84        self.flag_edit.setPlaceholderText("e.g. -O3 -fcheck=all")
 85        form.addRow("Fortran flags:", self.flag_edit)
 86        self.target_edit = QLineEdit()
 87        self.target_edit.setPlaceholderText("all targets")
 88        form.addRow("Target:", self.target_edit)
 89        self.extra_edit = QLineEdit()
 90        self.extra_edit.setPlaceholderText("extra fpm arguments")
 91        form.addRow("Extra args:", self.extra_edit)
 92        self.runner_args_edit = QLineEdit()
 93        self.runner_args_edit.setPlaceholderText("arguments passed to the program")
 94        form.addRow("Program args:", self.runner_args_edit)
 95        self.test_jobs_spin = QSpinBox()
 96        self.test_jobs_spin.setRange(1, 256)
 97        self.test_jobs_spin.setSpecialValueText("off (one at a time)")
 98        self.test_jobs_spin.setSuffix(" at a time")
 99        form.addRow("Parallel tests:", self.test_jobs_spin)
100        self.coverage_check = QCheckBox("Coverage")
101        form.addRow(self.coverage_check)
102        self.verbose_check = QCheckBox("Verbose")
103        form.addRow(self.verbose_check)
104        fpm_row = QHBoxLayout()
105        self.fpm_edit = QLineEdit()
106        self.fpm_edit.setPlaceholderText(shutil.which("fpm") or "fpm")
107        fpm_browse = QToolButton()
108        fpm_browse.setText("…")
109        fpm_browse.clicked.connect(self._browse_fpm)
110        fpm_row.addWidget(self.fpm_edit)
111        fpm_row.addWidget(fpm_browse)
112        # A line above it: the fpm executable isn't part of a build configuration (a path on this computer).
113        divider = QFrame()
114        divider.setFrameShape(QFrame.HLine)
115        divider.setFrameShadow(QFrame.Sunken)
116        form.addRow(divider)
117        form.addRow("fpm executable:", fpm_row)
118
119        # Hovering an option (or its label) shows the fpm flag it becomes.
120        for widget, flag, text in [
121            (self.profile_combo, "--profile PROF",
122             ("Build profile: debug or release. (default) passes none: fpm then uses no profile's flags "
123              "(no -g; Start Debugging uses debug).")),
124            (self.features_button, "--features LIST",
125             "Features to enable (the project's fpm.toml declares them). Can't be combined with a profile."),
126            (self.compiler_edit, "--compiler NAME", "Fortran compiler to use. fpm uses gfortran by default."),
127            (self.flag_edit, "--flag FFLAGS", "Extra Fortran compiler flags."),
128            (self.target_edit, "fpm run|test NAME…",
129             "Names of the programs or tests to run (wildcards allowed). Used by Run and Test."),
130            (self.extra_edit, "(added as typed)",
131             "Any other fpm options, e.g. --runner \"mpirun -np 4\" or --c-compiler gcc."),
132            (self.runner_args_edit, "-- ARGS",
133             "Arguments passed to the program or tests by Run and Test."),
134            (self.test_jobs_spin, "(not a flag)",
135             (f"Run this many tests at once (this computer has {os.cpu_count() or 1} cores). fpm builds "
136              "them; the app runs them, each with its own output. Off: fpm runs them one after another.")),
137            (self.coverage_check, "--flag --coverage --link-flag --coverage",
138             ("Build with gfortran's coverage counting (a build of its own): after Test, the Coverage panel "
139              "and the editor's gutter show which lines the tests ran.")),
140            (self.verbose_check, "--verbose", "Print more detail about what fpm is doing."),
141            (self.fpm_edit, "(not a flag)",
142             ("The fpm program to run. Leave empty to use the fpm on your PATH. Not part of a build "
143              "configuration: it's kept for this computer.")),
144        ]:
145            tip = f"<b><code>{html.escape(flag)}</code></b><br>{html.escape(text)}"
146            widget.setToolTip(tip)
147            label = form.labelForField(widget) or form.labelForField(fpm_row)
148            if label is not None and widget is not self.verbose_check:
149                label.setToolTip(tip)
150        self._watch_option_fields()
151
152        return options
153
154    def _build_commands(self):
155        """The fpm commands as actions, shown in a toolbar and a Commands menu."""
156        self.command_icons: dict[QAction, str] = {}
157        if hasattr(self, "palette_action"):  # (View → Command Palette…: its icon, redrawn with the theme's colors too)
158            self.command_icons[self.palette_action] = "palette"
159        if hasattr(self, "go_to_file_action"):  # (Edit → Go To → Go to File…: the same)
160            self.command_icons[self.go_to_file_action] = "find-file"
161
162        def make(label, icon, shortcut, description, slot):
163            action = QAction(label, self)
164            if shortcut:
165                action.setShortcut(QKeySequence(shortcut))
166                keys = QKeySequence(shortcut).toString(QKeySequence.NativeText)
167                action.setToolTip(f"{label} ({keys}) — {description}")
168            else:
169                action.setToolTip(f"{label} — {description}")
170            action.setStatusTip(description)
171            action.triggered.connect(slot)
172            self.command_icons[action] = icon
173            return action
174
175        actions = {}
176        for label, args, uses_options, refresh, icon, shortcut, description in COMMANDS:
177            if args == ["test"]:
178                slot = self.run_tests
179            elif args == ["run"]:
180                slot = self.run_programs
181            else:
182                slot = lambda _=False, a=args, o=uses_options, r=refresh: self.run_fpm(a, o, r)
183            actions[icon] = make(label, icon, shortcut, description, slot)
184            self.command_actions.append(actions[icon])
185        self.stop_action = make("Stop", "stop", "Ctrl+.", "Stop the running fpm command", self.stop)
186        self.stop_action.setEnabled(False)
187
188        groups = [[actions["build"], actions["build-tests"], actions["run"], actions["test"], self.stop_action],
189                  [actions["update"]],
190                  [actions["clean"], actions["clean-all"]]]
191        toolbar = QToolBar("Commands")
192        toolbar.setObjectName("CommandsToolbar")
193        toolbar.setMovable(False)
194        toolbar.setFloatable(False)
195        toolbar.setToolButtonStyle(Qt.ToolButtonTextUnderIcon)
196        toolbar.setIconSize(QSize(22, 22))
197        toolbar.toggleViewAction().setEnabled(False)  # don't let it be hidden by accident
198        menu = self.menuBar().addMenu("&Commands")
199        for i, group in enumerate(groups):
200            if i:
201                toolbar.addSeparator()
202                menu.addSeparator()
203            toolbar.addActions(group)
204            menu.addActions(group)
205        menu.addSeparator()
206        self.check_updates_action = menu.addAction("Check for Dependency Updates…")
207        self.check_updates_action.setStatusTip(
208            "Ask each git dependency's repository for newer tags and commits")
209        self.check_updates_action.triggered.connect(self.check_dependency_updates)
210        self.addToolBar(Qt.TopToolBarArea, toolbar)
211        self.setUnifiedTitleAndToolBarOnMac(True)
212        self.command_toolbar = toolbar
213        # Right-click: the toolbar's look (View → Toolbar's choices; Qt's own menu would list the panels).
214        toolbar.setContextMenuPolicy(Qt.CustomContextMenu)
215        toolbar.customContextMenuRequested.connect(
216            lambda pos: self.toolbar_menu.popup(toolbar.mapToGlobal(pos)))
217        self._update_command_icons()
218
219    def _update_command_icons(self):
220        """(Re)draw the toolbar icons in the current text color."""
221        text = QApplication.palette().color(QPalette.WindowText)
222        for action, name in self.command_icons.items():
223            action.setIcon(make_icon(name, STOP_COLOR if name == "stop" else text))
224        # The ">>" button shown when the window is too narrow for every
225        # button. Qt 6.11 draws its default arrow with a macOS symbol image,
226        # which crashes in AppKit on macOS 27, so give it our own icon.
227        extension = self.command_toolbar.findChild(QToolButton, "qt_toolbar_ext_button")
228        if extension is not None:
229            extension.setIcon(make_icon("more", text))
230        hover = "rgba(128,128,128,45)"
231        self.command_toolbar.setStyleSheet(f"""
232            QToolBar#CommandsToolbar {{ border: none; spacing: 2px; padding: 2px 6px; }}
233            QToolBar#CommandsToolbar QToolButton {{ border: none; border-radius: 7px;
234                padding: 4px 10px; }}
235            QToolBar#CommandsToolbar QToolButton:hover {{ background: {hover}; }}
236            QToolBar#CommandsToolbar QToolButton:pressed {{ background: rgba(128,128,128,80); }}
237            QToolBar#CommandsToolbar QToolButton#qt_toolbar_ext_button {{ padding: 0px; }}
238        """)
239
240    def busy(self) -> bool:
241        """A command is running (fpm, a program, or tests run in parallel)."""
242        return self.process is not None or self.parallel_tests is not None
243
244    def _set_project_enabled(self, enabled: bool):
245        running = self.busy()
246        for action in self.command_actions:
247            action.setEnabled(enabled and not running)
248
249    def effective_compiler(self) -> tuple[str, str]:
250        """(compiler, where the choice comes from), the way fpm picks it:
251        --compiler (the Compiler field), else $FPM_FC, else gfortran."""
252        typed = self.compiler_edit.text().strip()
253        if typed:
254            return typed, "Compiler field in Build options"
255        env = os.environ.get("FPM_FC", "").strip()
256        if env:
257            return env, "FPM_FC environment variable"
258        return "gfortran", "fpm's default"
259
260    def _browse_fpm(self):
261        filename, _ = QFileDialog.getOpenFileName(self, "Select fpm executable")
262        if filename:
263            self.fpm_edit.setText(filename)
264
265    def _fpm_executable(self) -> str:
266        return self.fpm_edit.text().strip() or shutil.which("fpm") or "fpm"
267
268    def build_arguments(self, sub: list[str], uses_options: bool,
269                        program_args: bool = True, targets: list[str] | None = None) -> list[str]:
270        """fpm's arguments for ``sub``; ``targets``: these programs or tests
271        (instead of the Target field's)."""
272        args = list(sub)
273        if uses_options:
274            if self.selected_features:  # fpm rejects --features together with --profile
275                args += ["--features", ",".join(self.selected_features)]
276            elif self.profile_combo.currentIndex() > 0:
277                args += ["--profile", self.profile_combo.currentText()]
278            if self.compiler_edit.text().strip():
279                args += ["--compiler", self.compiler_edit.text().strip()]
280            coverage = self.coverage_check.isChecked() and sub[0] in ("build", "run", "test", "install")
281            flags = " ".join(f for f in (self.flag_edit.text().strip(), cov.FLAG if coverage else "") if f)
282            if flags:
283                args += ["--flag", flags]
284            if self.verbose_check.isChecked():
285                args.append("--verbose")
286            names = split_args(self.target_edit.text()) if targets is None else targets
287            if sub[0] in ("run", "test") and names:
288                args += names
289            extra = split_args(self.extra_edit.text())
290            if sub[0] in ("test", "run"):
291                extra, _ = split_runner(extra)  # (the user's --runner: run inside ours, see _start_marked)
292            if coverage:  # (linked with it too: the user's own --link-flag gets it added)
293                extra = with_link_flag(extra, cov.FLAG)
294            args += extra
295            if program_args and sub[0] in ("run", "test") and self.runner_args_edit.text().strip():
296                args += ["--"] + split_args(self.runner_args_edit.text())
297        elif sub[0] == "update" and self.verbose_check.isChecked():
298            args.append("--verbose")
299        return args
300
301    def _build_features_menu(self):
302        """The Features button's menu in Build options (filled from the project's manifest)."""
303        self.features_menu = StayOpenMenu(self)
304        self.features_button.setMenu(self.features_menu)
305        self.project_features: dict[str, list[str]] = {}
306        self.selected_features: list[str] = []
307        self._wanted_profile = "(default)"
308        self._rebuild_features_menu()
309
310    def _load_features_and_profiles(self):
311        """Read [features] and [profiles] from the project's fpm.toml."""
312        try:
313            data = m.load_toml(self.manifest_path)
314        except (OSError, tomllib.TOMLDecodeError):
315            data = {}
316        self.project_features = m.project_features(data)
317        profiles = m.project_profiles(data)
318
319        # Profile list: built-in ones plus the project's feature profiles.
320        current = self.profile_combo.currentText() if self.profile_combo.count() else self._wanted_profile
321        self.profile_combo.blockSignals(True)
322        self.profile_combo.clear()
323        self.profile_combo.addItems(BUILTIN_PROFILES)
324        for name, features in profiles.items():
325            if name not in BUILTIN_PROFILES:
326                self.profile_combo.addItem(name)
327                index = self.profile_combo.count() - 1
328                self.profile_combo.setItemData(
329                    index, f"Feature profile: {', '.join(features) or '(no features)'}", Qt.ToolTipRole)
330        if self.profile_combo.findText(current) >= 0:
331            self.profile_combo.setCurrentText(current)
332        self.profile_combo.blockSignals(False)
333
334        if not self.config_shown():  # (a configuration's own features stay; see vibefpmgui.window.configs)
335            saved = self._saved_free_options().features
336            self.selected_features = [f for f in saved if f in self.project_features]
337        self._rebuild_features_menu()
338
339    def _rebuild_features_menu(self):
340        menu = self.features_menu
341        menu.clear()
342        if not self.project_features:
343            none = menu.addAction("No features defined in fpm.toml")
344            none.setEnabled(False)
345        self.feature_actions: dict[str, QAction] = {}
346        for name, props in self.project_features.items():
347            action = menu.addAction(name)
348            action.setCheckable(True)
349            action.setChecked(name in self.selected_features)
350            self.feature_actions[name] = action
351            shown = ", ".join(props[:8]) + (" …" if len(props) > 8 else "")
352            action.setToolTip(f"Sets: {shown}" if props else name)
353            action.setStatusTip(f"Feature {name} — sets {shown}" if props else f"Feature {name}")
354            action.toggled.connect(lambda on, n=name: self._toggle_feature(n, on))
355        menu.setToolTipsVisible(True)
356        self.clear_features_action = None
357        if self.project_features:
358            menu.addSeparator()
359            self.clear_features_action = menu.addAction("Clear all features")
360            self.clear_features_action.triggered.connect(self._clear_features)
361        self._update_features_ui()
362
363    def _toggle_feature(self, name: str, on: bool):
364        if on and name not in self.selected_features:
365            # Keep the order they're declared in fpm.toml.
366            chosen = set(self.selected_features) | {name}
367            self.selected_features = [f for f in self.project_features if f in chosen]
368        elif not on and name in self.selected_features:
369            self.selected_features.remove(name)
370        self._save_features()
371        # Only update the existing actions: rebuilding the menu here would
372        # delete the action whose signal is being handled.
373        self._update_features_ui()
374
375    def _clear_features(self):
376        self.selected_features = []
377        self._save_features()
378        self._update_features_ui()
379
380    def _save_features(self):
381        """New configuration's features (not a saved configuration's, being edited)."""
382        if self.manifest_path and not self.config_shown():
383            self._keep_free_options(self.current_options())
384
385    def _update_features_ui(self):
386        """Show the selection, and disable Profile while features are on."""
387        locked = self.options_locked()  # (a saved configuration's: read-only)
388        for name, action in getattr(self, "feature_actions", {}).items():
389            if action.isChecked() != (name in self.selected_features):
390                action.blockSignals(True)
391                action.setChecked(name in self.selected_features)
392                action.blockSignals(False)
393            action.setEnabled(not locked)
394        if getattr(self, "clear_features_action", None) is not None:
395            self.clear_features_action.setEnabled(bool(self.selected_features) and not locked)
396        if self.selected_features:
397            shown = ", ".join(self.selected_features)
398        else:
399            shown = "none" if self.project_features else "none defined in fpm.toml"
400        self.features_button.set_full_text(shown)
401        self.features_button.setEnabled(bool(self.project_features) and not locked)
402        self.features_button.setToolTip(f"<b><code>--features LIST</code></b><br>{html.escape(shown)}<br>"
403                                        "Features to enable (the project's fpm.toml declares them). Can't be "
404                                        "combined with a profile.")
405        using = bool(self.selected_features)
406        self.profile_combo.setEnabled(not using and not locked)
407        self.profile_combo.setToolTip(
408            "<b><code>--profile PROF</code></b><br>"
409            + ("Disabled while features are selected: fpm can't combine --profile and --features."
410               if using else "Build profile: debug, release, or a feature profile from fpm.toml."))
411        self._options_changed()  # (the features are options too)
412
413    def _build_description(self) -> str:
414        compiler, _source = self.effective_compiler()
415        if self.selected_features:
416            return f"features: {', '.join(self.selected_features)} · {compiler}"
417        profile = self.profile_combo.currentText() if self.profile_combo.currentIndex() > 0 else "debug"
418        return f"{profile} · {compiler}"
419
420    def run_fpm(self, sub: list[str], uses_options: bool, refresh: bool,
421                phase: str | None = None, args: list[str] | None = None,
422                env_extra: dict[str, str] | None = None):
423        if not self.manifest_path or self.busy():
424            return
425        if args is None:
426            try:
427                args = self.build_arguments(sub, uses_options)
428            except ValueError as exc:  # unbalanced quotes in a text field
429                QMessageBox.warning(self, "Invalid arguments", str(exc))
430                return
431        program = self._fpm_executable()
432        self.process = QProcess(self)
433        self.process.setWorkingDirectory(str(self.manifest_path.parent))
434        self.process.setProcessChannelMode(QProcess.MergedChannels)
435        env = QProcessEnvironment.systemEnvironment()
436        env.insert("NO_COLOR", "1")
437        for key, value in (env_extra or {}).items():
438            env.insert(key, value)
439        self.process.setProcessEnvironment(env)
440        self.process.readyReadStandardOutput.connect(self._read_output)
441        self.process.finished.connect(self._finished)
442        self.process.errorOccurred.connect(self._process_error)
443        self._refresh_after = refresh
444        self._partial = ""
445        self._phase = phase
446        self._captured = []
447        self._held_blank = False
448        # Commands that compile get the per-file build grid. "fpm test --list"
449        # doesn't build; "fpm test" builds first, so its output goes to the
450        # build grid until the first test starts (see _test_line).
451        self._monitoring = sub[0] in ("build", "run", "test") and phase != "list"
452        self._test_building = phase in ("marked", "collect", "debug")  # fpm builds before running
453        self._process_label = "fpm"
454        self._revs_before = (dep_updates.read_cache_revs(self.manifest_path.parent)
455                             if sub[0] == "update" else None)
456        # Note which build folders exist now, to record which ones this
457        # build writes (see _record_build_folders).
458        self._folder_snapshot = (bo.snapshot(self.manifest_path.parent)
459                                 if self._monitoring else None)
460        if self._monitoring:
461            self.build_monitor.start(self.graph, self.manifest_path.parent, self._build_description())
462            if phase is None:
463                self.show_all_output()  # any file shown on its own is gone
464            # Show the build's progress (Test switches to its tab once the
465            # tests themselves start).
466            self.show_panel(self.build_monitor)
467
468        self._append(f"\n$ cd {quote_arg(str(self.manifest_path.parent))}\n"
469                     f"$ {join_args([program] + args)}\n", bold=True)
470        self.stop_action.setEnabled(True)
471        self._set_project_enabled(True)
472        self.statusBar().showMessage(f"Running fpm {' '.join(sub)}…")
473        self._esc_tail = ""
474        self.console_writer.reset()
475        self._begin_output()
476        self.process.start(program, args)
477
478    def stop(self):
479        if self.process is not None:
480            # (Remembered: the process may still end normally. Its children go first,
481            # and fpm, seeing its test program killed, can exit with its code before
482            # it's killed too: the run is the one stopped, not a failure.)
483            self._stop_requested = True
484            kill_process_tree(self.process)
485        if self.parallel_tests is not None:
486            self.parallel_tests.stop()
487
488    def _stop_and_wait(self, msecs: int):
489        if self.process is not None:
490            kill_process_tree(self.process)
491            self.process.waitForFinished(msecs)
492        if self.parallel_tests is not None:
493            self.parallel_tests.stop()
494            self.parallel_tests.wait(msecs)
495
496    def _read_output(self, final: bool = False):
497        """New output from the running command; ``final``: it has finished
498        (bytes of an unfinished character are shown as "�")."""
499        if self.process is None:
500            return
501        data = self._decoder.decode(bytes(self.process.readAllStandardOutput()), final=final)
502        if not data:
503            return
504        data, self._esc_tail = ansi.split_partial(self._esc_tail + data)
505        # fpm redraws progress lines with \r; keep only the final state of each line.
506        data = data.replace("\r\n", "\n")
507        data = "\n".join(line.rsplit("\r", 1)[-1] for line in data.split("\n"))
508        if self._phase in ("marked", "direct", "collect", "debug"):
509            # Line-buffered so the test markers can be taken out of the output.
510            lines = (self._partial + data).split("\n")
511            self._partial = lines.pop()
512            handle = self._line_handler()
513            self._append("".join(handle(line) for line in lines), colored=True)
514            return
515        self._append(data, colored=True)
516        plain = ansi.strip(data)  # color codes would get in the way of parsing
517        if self._phase in ("list", "valgrind"):
518            self._captured.append(plain)
519        if self._monitoring:
520            # Output can arrive mid-line; only parse complete lines.
521            lines = (self._partial + plain).split("\n")
522            self._partial = lines.pop()
523            for line in lines:
524                self.build_monitor.feed_line(line)
525
526    def _line_handler(self):
527        """What reads each line of a run's output, per phase."""
528        return {"collect": self._collect_line, "debug": self._debug_line}.get(self._phase, self._test_line)
529
530    def _build_part_done(self):
531        """The build part of "fpm test" is over: show the tests."""
532        self._test_building = False
533        if self._monitoring:
534            self.build_monitor.finish(0)
535            self._update_tree_marks()
536            self._monitoring = False
537        self._marker_monitor().build_finished()
538        self.show_panel(self._marker_monitor())
539
540    def _flush_monitor(self, exit_code: int, stopped: bool):
541        if self._phase in ("marked", "direct", "collect", "debug"):
542            if self._partial:
543                handle = self._line_handler()
544                self._append(handle(self._partial), colored=True)
545            if self._held_blank:
546                self._append("\n")
547            self._partial = ""
548            self._held_blank = False
549        if self._monitoring:
550            if self._partial:
551                self.build_monitor.feed_line(self._partial)
552            self._partial = ""
553            self.build_monitor.finish(exit_code, stopped)
554            self._update_tree_marks()
555            self._monitoring = False
556
557    def _finished(self, code: int, status):
558        self._read_output(final=True)
559        crashed = status == QProcess.CrashExit or self._stop_requested  # (stopped)
560        self._flush_monitor(code, crashed)
561        self._end_output()
562        if crashed:
563            self._append("\n[process was stopped]\n", bold=True)
564        else:
565            self._append(f"\n[{self._process_label} exited with code {code}]\n", bold=True)
566        self._record_build_folders()
567        phase, self._phase = self._phase, None
568        self._cleanup_process()
569        self.statusBar().showMessage("Stopped" if crashed else
570                                     ("Finished successfully" if code == 0 else f"Failed (exit code {code})"))
571        self._report_dependency_changes()  # after the generic message, so it shows
572        if phase == "list":
573            self._after_listing(code, crashed)
574        elif phase in ("marked", "direct"):
575            self._after_marked(code, crashed)
576        elif phase == "collect":
577            self._after_collect(code, crashed)
578        elif phase == "debug":
579            self._after_debug_build(code, crashed)
580        elif phase == "valgrind":
581            self._after_valgrind(code, crashed)
582
583    def _process_error(self, error):
584        if error == QProcess.FailedToStart:
585            if getattr(self, "_process_label", "fpm") == "fpm":
586                self._append(f"\nCould not start '{self._fpm_executable()}'. "
587                             "Set the fpm executable in Build options.\n", bold=True)
588            else:
589                self._append(f"\nCould not start '{self._process_label}'.\n", bold=True)
590            self.statusBar().showMessage(f"{getattr(self, '_process_label', 'fpm')} failed to start")
591            self._flush_monitor(-1, True)
592            self._end_output()
593            if self._phase is not None:
594                self._marker_monitor().finish(-1, stopped=True)
595            self._phase = None
596            self._cleanup_process()
597
598    def _cleanup_process(self):
599        if self.process is None:
600            return
601        self.process.deleteLater()
602        self.process = None
603        self._stop_requested = False
604        self.stop_action.setEnabled(False)
605        self._set_project_enabled(self.graph is not None)
606        if self._refresh_after:
607            self.reload()

Running fpm (a part of vibefpmgui.main_window.MainWindow).

def busy(self) -> bool:
240    def busy(self) -> bool:
241        """A command is running (fpm, a program, or tests run in parallel)."""
242        return self.process is not None or self.parallel_tests is not None

A command is running (fpm, a program, or tests run in parallel).

def effective_compiler(self) -> tuple[str, str]:
249    def effective_compiler(self) -> tuple[str, str]:
250        """(compiler, where the choice comes from), the way fpm picks it:
251        --compiler (the Compiler field), else $FPM_FC, else gfortran."""
252        typed = self.compiler_edit.text().strip()
253        if typed:
254            return typed, "Compiler field in Build options"
255        env = os.environ.get("FPM_FC", "").strip()
256        if env:
257            return env, "FPM_FC environment variable"
258        return "gfortran", "fpm's default"

(compiler, where the choice comes from), the way fpm picks it: --compiler (the Compiler field), else $FPM_FC, else gfortran.

def build_arguments( self, sub: list[str], uses_options: bool, program_args: bool = True, targets: list[str] | None = None) -> list[str]:
268    def build_arguments(self, sub: list[str], uses_options: bool,
269                        program_args: bool = True, targets: list[str] | None = None) -> list[str]:
270        """fpm's arguments for ``sub``; ``targets``: these programs or tests
271        (instead of the Target field's)."""
272        args = list(sub)
273        if uses_options:
274            if self.selected_features:  # fpm rejects --features together with --profile
275                args += ["--features", ",".join(self.selected_features)]
276            elif self.profile_combo.currentIndex() > 0:
277                args += ["--profile", self.profile_combo.currentText()]
278            if self.compiler_edit.text().strip():
279                args += ["--compiler", self.compiler_edit.text().strip()]
280            coverage = self.coverage_check.isChecked() and sub[0] in ("build", "run", "test", "install")
281            flags = " ".join(f for f in (self.flag_edit.text().strip(), cov.FLAG if coverage else "") if f)
282            if flags:
283                args += ["--flag", flags]
284            if self.verbose_check.isChecked():
285                args.append("--verbose")
286            names = split_args(self.target_edit.text()) if targets is None else targets
287            if sub[0] in ("run", "test") and names:
288                args += names
289            extra = split_args(self.extra_edit.text())
290            if sub[0] in ("test", "run"):
291                extra, _ = split_runner(extra)  # (the user's --runner: run inside ours, see _start_marked)
292            if coverage:  # (linked with it too: the user's own --link-flag gets it added)
293                extra = with_link_flag(extra, cov.FLAG)
294            args += extra
295            if program_args and sub[0] in ("run", "test") and self.runner_args_edit.text().strip():
296                args += ["--"] + split_args(self.runner_args_edit.text())
297        elif sub[0] == "update" and self.verbose_check.isChecked():
298            args.append("--verbose")
299        return args

fpm's arguments for sub; targets: these programs or tests (instead of the Target field's).

def run_fpm( self, sub: list[str], uses_options: bool, refresh: bool, phase: str | None = None, args: list[str] | None = None, env_extra: dict[str, str] | None = None):
420    def run_fpm(self, sub: list[str], uses_options: bool, refresh: bool,
421                phase: str | None = None, args: list[str] | None = None,
422                env_extra: dict[str, str] | None = None):
423        if not self.manifest_path or self.busy():
424            return
425        if args is None:
426            try:
427                args = self.build_arguments(sub, uses_options)
428            except ValueError as exc:  # unbalanced quotes in a text field
429                QMessageBox.warning(self, "Invalid arguments", str(exc))
430                return
431        program = self._fpm_executable()
432        self.process = QProcess(self)
433        self.process.setWorkingDirectory(str(self.manifest_path.parent))
434        self.process.setProcessChannelMode(QProcess.MergedChannels)
435        env = QProcessEnvironment.systemEnvironment()
436        env.insert("NO_COLOR", "1")
437        for key, value in (env_extra or {}).items():
438            env.insert(key, value)
439        self.process.setProcessEnvironment(env)
440        self.process.readyReadStandardOutput.connect(self._read_output)
441        self.process.finished.connect(self._finished)
442        self.process.errorOccurred.connect(self._process_error)
443        self._refresh_after = refresh
444        self._partial = ""
445        self._phase = phase
446        self._captured = []
447        self._held_blank = False
448        # Commands that compile get the per-file build grid. "fpm test --list"
449        # doesn't build; "fpm test" builds first, so its output goes to the
450        # build grid until the first test starts (see _test_line).
451        self._monitoring = sub[0] in ("build", "run", "test") and phase != "list"
452        self._test_building = phase in ("marked", "collect", "debug")  # fpm builds before running
453        self._process_label = "fpm"
454        self._revs_before = (dep_updates.read_cache_revs(self.manifest_path.parent)
455                             if sub[0] == "update" else None)
456        # Note which build folders exist now, to record which ones this
457        # build writes (see _record_build_folders).
458        self._folder_snapshot = (bo.snapshot(self.manifest_path.parent)
459                                 if self._monitoring else None)
460        if self._monitoring:
461            self.build_monitor.start(self.graph, self.manifest_path.parent, self._build_description())
462            if phase is None:
463                self.show_all_output()  # any file shown on its own is gone
464            # Show the build's progress (Test switches to its tab once the
465            # tests themselves start).
466            self.show_panel(self.build_monitor)
467
468        self._append(f"\n$ cd {quote_arg(str(self.manifest_path.parent))}\n"
469                     f"$ {join_args([program] + args)}\n", bold=True)
470        self.stop_action.setEnabled(True)
471        self._set_project_enabled(True)
472        self.statusBar().showMessage(f"Running fpm {' '.join(sub)}…")
473        self._esc_tail = ""
474        self.console_writer.reset()
475        self._begin_output()
476        self.process.start(program, args)
def stop(self):
478    def stop(self):
479        if self.process is not None:
480            # (Remembered: the process may still end normally. Its children go first,
481            # and fpm, seeing its test program killed, can exit with its code before
482            # it's killed too: the run is the one stopped, not a failure.)
483            self._stop_requested = True
484            kill_process_tree(self.process)
485        if self.parallel_tests is not None:
486            self.parallel_tests.stop()