vibefpmgui.window.configs

Build configurations (see vibefpmgui.build_configs): the Configuration list at the top of Build options (its ⋯ menu opens another configuration file), and File → Save Build Configuration…. (A project opened with File → Open Project… or Recent Projects comes with its vibefpm.json: see vibefpmgui.window.project.)

"New configuration" is the Build options as before: editable, kept in the app's settings. Choosing a saved configuration shows its options, read-only (the fpm executable stays editable: it isn't part of one); choosing New configuration again brings back what was there. Save… adds what's shown to the project's configuration file, as a configuration chosen from then on. Edit makes a saved configuration's options editable, and Save puts them back in the file under its name; leaving it (choosing another, opening another project or file, quitting) with changes not saved asks first.

  1"""Build configurations (see `vibefpmgui.build_configs`): the Configuration
  2list at the top of Build options (its ⋯ menu opens another configuration
  3file), and File → Save Build Configuration…. (A project opened with File →
  4Open Project… or Recent Projects comes with its vibefpm.json: see
  5vibefpmgui.window.project.)
  6
  7"New configuration" is the Build options as before: editable, kept in the
  8app's settings. Choosing a saved configuration shows its options, read-only
  9(the fpm executable stays editable: it isn't part of one); choosing New
 10configuration again brings back what was there. Save… adds what's shown to
 11the project's configuration file, as a configuration chosen from then on.
 12Edit makes a saved configuration's options editable, and Save puts them back
 13in the file under its name; leaving it (choosing another, opening another
 14project or file, quitting) with changes not saved asks first.
 15"""
 16
 17from __future__ import annotations
 18
 19from pathlib import Path
 20
 21from qtpy.QtCore import QSize, Qt
 22from qtpy.QtGui import QPalette
 23from qtpy.QtWidgets import (
 24    QApplication,
 25    QComboBox,
 26    QFileDialog,
 27    QHBoxLayout,
 28    QInputDialog,
 29    QLabel,
 30    QMenu,
 31    QMessageBox,
 32    QToolButton,
 33)
 34
 35from .. import build_configs as bc
 36from ..icons import make_icon
 37from .options import project_key
 38
 39NEW_CONFIGURATION = "New configuration"
 40
 41
 42class ConfigsMixin:
 43    """Build configurations (a part of `vibefpmgui.main_window.MainWindow`)."""
 44
 45    _free_options: bc.Configuration | None = None  # New configuration's options, while a saved one is shown
 46    _editing = ""  # the saved configuration being edited (its options editable), if any
 47    _listed_file: bc.ConfigFile | None = None  # the file the list is of
 48
 49    # (The project's configuration file: config_file, see vibefpmgui.window.options.)
 50    @property
 51    def configurations(self) -> list[bc.Configuration]:
 52        return self.config_file.configurations if self.config_file is not None else []
 53
 54    @property
 55    def _config_error(self) -> str:
 56        """Why the configuration file can't be read ("" if it can)."""
 57        return self.config_file.error if self.config_file is not None else ""
 58
 59    def _build_config_row(self, form):
 60        """The Configuration row (the first of Build options) and the
 61        read-only note under it."""
 62        self.config_combo = QComboBox()
 63        self.config_combo.addItem(NEW_CONFIGURATION)
 64        # (As narrow as the panel gets: not as wide as the longest name.)
 65        self.config_combo.setSizeAdjustPolicy(QComboBox.AdjustToMinimumContentsLengthWithIcon)
 66        self.config_combo.setMinimumContentsLength(6)
 67        self.config_combo.currentIndexChanged.connect(self._config_chosen)
 68        self.config_edit_button = QToolButton()
 69        self.config_edit_button.setText("Edit")
 70        self.config_edit_button.clicked.connect(self._edit_clicked)
 71        self.config_save_button = QToolButton()
 72        self.config_save_button.setText("Save…")
 73        self.config_save_button.clicked.connect(self.save_configuration)
 74        more = QToolButton()
 75        more.setText("⋯")
 76        more.setToolTip("More: save as, edit a copy, delete, open a configuration file")
 77        menu = QMenu(more)
 78        self.config_save_as_action = menu.addAction("Save As…", self.save_configuration_as)
 79        self.config_save_as_action.setToolTip("Save the options shown as a configuration under another name")
 80        self.config_edit_action = menu.addAction("Edit as New Configuration", self.edit_configuration_as_new)
 81        self.config_edit_action.setToolTip("Copy this configuration's options to New configuration, to change "
 82                                           "them (and Save…, under its name or another)")
 83        self.config_delete_action = menu.addAction("Delete Configuration…", self.delete_configuration)
 84        menu.addSeparator()
 85        menu.addAction("Open Configuration File…", self.browse_configuration)
 86        self.config_show_action = menu.addAction("Show Configuration File in Editor", self._show_config_file)
 87        menu.setToolTipsVisible(True)
 88        # (Opened by hand: a button's own menu adds an arrow beside the icon.)
 89        more.clicked.connect(lambda: menu.popup(more.mapToGlobal(more.rect().bottomLeft())))
 90        self._config_more_menu = menu
 91        self.config_more_button = more
 92        # Icon buttons, as the Debug and Git panels' (the texts kept, for the tooltips' and
 93        # screen readers' sake; the icons: see _draw_config_icons).
 94        for button in (self.config_edit_button, self.config_save_button, more):
 95            button.setToolButtonStyle(Qt.ToolButtonIconOnly)
 96            button.setIconSize(QSize(18, 18))
 97            button.setAutoRaise(True)
 98        row = QHBoxLayout()
 99        row.addWidget(self.config_combo, 1)
100        row.addWidget(self.config_edit_button)
101        row.addWidget(self.config_save_button)
102        row.addWidget(more)
103        form.addRow(row)  # (the panel's whole width: with a label, it made the panel wider than its fields)
104        self.config_note = QLabel()
105        self.config_note.setWordWrap(True)
106        self.config_note.hide()
107        form.addRow(self.config_note)
108        self._update_config_ui()
109
110    # ------------------------------------------------------------ options
111    def _option_fields(self) -> list:
112        """The fields a configuration sets (not the fpm executable)."""
113        return [self.profile_combo, self.compiler_edit, self.flag_edit, self.target_edit, self.extra_edit,
114                self.runner_args_edit, self.test_jobs_spin, self.coverage_check, self.verbose_check]
115
116    def _watch_option_fields(self):
117        """(After the fields are made:) a change shows whether there are unsaved ones."""
118        for widget in self._option_fields():
119            for signal in ("textChanged", "valueChanged", "toggled", "currentTextChanged"):
120                if hasattr(widget, signal):
121                    getattr(widget, signal).connect(self._options_changed)
122                    break
123
124    def _options_changed(self, *_):
125        if self._editing:
126            self._update_config_ui()
127
128    def config_shown(self) -> bool:
129        """A saved configuration is shown (read-only, or being edited)."""
130        return self.config_combo.currentIndex() > 0
131
132    def options_locked(self) -> bool:
133        """A saved configuration is shown, read-only (not being edited)."""
134        return self.config_shown() and not self._editing
135
136    def has_unsaved_configuration(self) -> bool:
137        """The configuration being edited has changes not in the file."""
138        return bool(self._editing) and self.current_options(self._editing) != self._configuration(self._editing)
139
140    def current_options(self, name: str = "") -> bc.Configuration:
141        """The Build options shown, as a configuration named ``name``."""
142        return bc.Configuration(
143            name, profile=self.profile_combo.currentText() or bc.DEFAULT_PROFILE,
144            features=list(self.selected_features), compiler=self.compiler_edit.text().strip(),
145            flags=self.flag_edit.text().strip(), target=self.target_edit.text().strip(),
146            extra_args=self.extra_edit.text().strip(), program_args=self.runner_args_edit.text().strip(),
147            parallel_tests=self.test_jobs_spin.value(), coverage=self.coverage_check.isChecked(),
148            verbose=self.verbose_check.isChecked())
149
150    def free_options(self) -> bc.Configuration:
151        """New configuration's options (those to remember), even while a saved one is shown."""
152        return self._free_options if self._free_options is not None else self.current_options()
153
154    def _show_options(self, config: bc.Configuration):
155        """Fill the fields from ``config``."""
156        combo = self.profile_combo
157        if combo.findText(config.profile) < 0:  # (a profile fpm.toml no longer has: shown as asked for)
158            combo.addItem(config.profile)
159        combo.setCurrentText(config.profile)
160        self.compiler_edit.setText(config.compiler)
161        self.flag_edit.setText(config.flags)
162        self.target_edit.setText(config.target)
163        self.extra_edit.setText(config.extra_args)
164        self.runner_args_edit.setText(config.program_args)
165        self.test_jobs_spin.setValue(config.parallel_tests)
166        self.coverage_check.setChecked(config.coverage)
167        self.verbose_check.setChecked(config.verbose)
168        self.selected_features = list(config.features)
169        self._update_features_ui()
170
171    def _set_options_locked(self, locked: bool):
172        """Greyed out (read-only fields looked the same as the others)."""
173        for widget in self._option_fields():
174            if widget is not self.profile_combo:  # (its state: see _update_features_ui)
175                widget.setEnabled(not locked)
176        self._update_features_ui()
177
178    # ------------------------------------------------------------ choosing
179    def _config_chosen(self, index: int):
180        if self._editing:
181            editing = self._editing
182            if not self.maybe_save_configuration():
183                self.config_combo.blockSignals(True)  # (Cancel: still on it, its changes kept)
184                self.config_combo.setCurrentIndex(self._index_of(editing))
185                self.config_combo.blockSignals(False)
186                return
187            self._editing = ""
188        if index <= 0:
189            if self._free_options is not None:
190                free, self._free_options = self._free_options, None
191                self._show_options(free)
192        else:
193            config = self._configuration(self.config_combo.itemText(index))
194            if config is None:
195                return
196            if self._free_options is None:
197                self._free_options = self.current_options()
198            self._show_options(config)
199        self._set_options_locked(index > 0)
200        if self.manifest_path is not None:
201            self.settings.setValue("build_configuration", self.config_combo.currentText() if index > 0 else "")
202        self._update_config_ui()
203
204    def _configuration(self, name: str) -> bc.Configuration | None:
205        return next((c for c in self.configurations if c.name == name), None)
206
207    def _index_of(self, name: str) -> int:
208        """The list's item of the saved configuration named ``name`` (0: New
209        configuration, if there's none; one may be named like it)."""
210        combo = self.config_combo
211        return next((i for i in range(1, combo.count()) if name and combo.itemText(i) == name), 0)
212
213    def select_configuration(self, name: str):
214        """Show the configuration named ``name`` ("": New configuration)."""
215        self.config_combo.setCurrentIndex(self._index_of(name))
216
217    # ------------------------------------------------------------ the file
218    def _load_configurations(self, reread: bool = True):
219        """The list of the project's configurations (the project was opened
220        or reloaded): its configurations read again (not for a file just
221        opened, or ``reread`` False: just read), the one shown kept (for a
222        file just opened, the one shown last time); one being edited keeps
223        its changes."""
224        file = self.config_file
225        if file is None or self.manifest_path is None:
226            return
227        first = file is not self._listed_file
228        if reread and not first:
229            file.reread_configurations()  # (not its options: they're the window's until written)
230        self._listed_file = file
231        if self._editing and not first:
232            self._refill_items(self._editing)
233            return
234        if first:
235            self._editing = ""  # (asked about before the project changed: see maybe_save_configuration)
236            wanted = self.settings.value("build_configuration", "") or ""
237        else:
238            wanted = self.config_combo.currentText() if self.options_locked() else ""
239        if self._config_error:
240            self.statusBar().showMessage(f"Build configurations: {self._config_error}", 8000)
241        self._fill_config_combo(wanted)
242
243    def reload_configurations(self):
244        """The configuration file was saved in the editor: its options, if
245        they were changed there, applied, and its configurations listed again
246        (the one shown kept: with its options as the file has them now,
247        unless it's being edited: its changes are kept)."""
248        if self.config_file is None or self.manifest_path is None:
249            return
250        self.reload_config_file()  # (it reads it all)
251        self._load_configurations(reread=False)
252        if self._config_error:
253            return  # (the list says why; it's empty until the file is fixed)
254        count = len(self.configurations)
255        self.statusBar().showMessage(f"Build configurations read again from {self.config_path.name}: {count} "
256                                     f"configuration{'s' * (count != 1)}", 5000)
257
258    def _fill_config_combo(self, wanted: str = ""):
259        """The list again, showing ``wanted`` (New configuration if it's gone):
260        back to New configuration first (its options restored), then to
261        ``wanted`` (its options read again: the file may have changed)."""
262        self.select_configuration("")
263        self._refill_items()
264        if wanted and self._configuration(wanted) is not None:
265            self.select_configuration(wanted)
266        self._update_config_ui()
267
268    def _refill_items(self, shown: str = ""):
269        """The list's names again, ``shown`` chosen (no options changed)."""
270        combo = self.config_combo
271        combo.blockSignals(True)
272        while combo.count() > 1:
273            combo.removeItem(1)
274        names = [config.name for config in self.configurations]
275        combo.addItems(names + ([shown] if shown and shown not in names else []))  # (being edited: kept)
276        combo.setCurrentIndex(self._index_of(shown))
277        combo.blockSignals(False)
278        self._update_config_ui()
279
280    def _draw_config_icons(self):
281        """The Configuration row's icons in the theme's text color (again when
282        it changes: see window.view): Edit a pencil (Cancel, while editing, an
283        ×), Save a disk, ⋯ three lines."""
284        color = QApplication.palette().color(QPalette.WindowText)
285        self.config_edit_button.setIcon(make_icon("close" if self._editing else "pencil", color))
286        self.config_save_button.setIcon(make_icon("save", color))
287        self.config_more_button.setIcon(make_icon("menu", color))
288
289    def _update_config_ui(self):
290        locked, editing = self.options_locked(), bool(self._editing)
291        unsaved = self.has_unsaved_configuration()
292        has_project = getattr(self, "manifest_path", None) is not None
293        self.config_edit_button.setText("Cancel" if editing else "Edit")
294        self._draw_config_icons()
295        self.config_edit_button.setEnabled(locked or editing)
296        self.config_edit_button.setToolTip(
297            "Stop editing: the configuration's options as saved" if editing else
298            "Change this configuration's options (then Save them under its name)" if locked else
299            "Choose a saved configuration to edit it")
300        self.config_save_button.setText("Save" if editing else "Save…")
301        self.config_save_button.setEnabled(has_project and (unsaved if editing else not locked))
302        self.config_save_button.setToolTip(
303            f"Save the changes to “{self._editing}”" if editing else
304            f"Save these options as a configuration in the project's {bc.FILE_NAME} (beside fpm.toml)")
305        self.config_save_as_action.setEnabled(has_project)
306        self.config_edit_action.setEnabled(self.config_shown())
307        self.config_delete_action.setEnabled(self.config_shown())
308        self.config_show_action.setEnabled(bool(self.config_path and self.config_path.is_file()))
309        where = self.config_path.name if self.config_path else bc.FILE_NAME
310        if self._config_error:
311            tip = f"⚠ {self._config_error}"
312            if self.options_in_use():  # (read when the project was opened: kept, not written over)
313                tip += "\nThe project's options aren't saved in it until it's fixed."
314        elif self.config_path and self.configurations:
315            tip = f"The configurations in {self.config_path}"
316        else:
317            tip = f"No saved configurations yet: Save… adds the options shown to {where}, beside fpm.toml"
318        self.config_combo.setToolTip(tip)
319        if locked:
320            self.config_note.setText(f"<span style='color:gray'>Read-only, from {where}.</span>")
321            self.config_note.setToolTip("Edit to change its options (then Save), or choose "
322                                        f"{NEW_CONFIGURATION}.")
323        elif editing:
324            self.config_note.setText(f"<span style='color:#e8590c'>Editing; changes not saved in {where}.</span>"
325                                     if unsaved else f"<span style='color:gray'>Editing (from {where}).</span>")
326            self.config_note.setToolTip("Save puts the changes in the file; Cancel goes back to it as saved.")
327        self.config_note.setVisible(locked or editing)
328        self.update_window_title()  # (the configuration chosen is in it)
329
330    def _remember_config_file(self, manifest: Path, path: Path):
331        """The configuration file the project uses from now on."""
332        self.settings.setValue(project_key(manifest, "build_config_file"),
333                               "" if path == bc.default_path(manifest) else str(path))
334
335    # ------------------------------------------------------------ commands
336    def save_configuration(self) -> bool:
337        """Build options → Save (File → Save Build Configuration…): the
338        configuration being edited, under its name; otherwise Save As…."""
339        if not self._editing:
340            return self.save_configuration_as()
341        name = self._editing
342        if not self._write_configuration(self.current_options(name)):
343            return False
344        self._editing = ""
345        self._fill_config_combo(name)
346        return True
347
348    def _config_file_for_saving(self) -> bc.ConfigFile | None:
349        """The file to save in (None: it can't be read, so isn't written over)."""
350        file = self.config_file
351        if file is None:
352            return None
353        if file.error and file.exists():
354            QMessageBox.warning(self, "Save Build Configuration",
355                                f"{file.error}\n\nFix the file (or move it away) to save configurations in it.")
356            return None
357        return file
358
359    def _write_configuration(self, config: bc.Configuration) -> bool:
360        file = self._config_file_for_saving()
361        if file is None:
362            return False
363        try:
364            file.save(config)
365        except (OSError, bc.ConfigError) as exc:
366            QMessageBox.warning(self, "Save Build Configuration", f"Couldn't save {file.path}:\n\n{exc}")
367            return False
368        self._remember_config_file(self.manifest_path, file.path)
369        self._config_file_saved()  # (a file just made: the project's options go in it from now on)
370        self.statusBar().showMessage(f"Saved build configuration “{config.name}” in {file.path.name}", 5000)
371        return True
372
373    def save_configuration_as(self) -> bool:
374        """Save… / ⋯ → Save As…: what's shown, as a configuration in the
375        project's file under a name asked for, chosen from then on."""
376        if self.manifest_path is None:
377            return False
378        file = self._config_file_for_saving()
379        if file is None:
380            return False
381        suggestion = self.config_combo.currentText() if self.config_shown() else ""
382        name, ok = QInputDialog.getText(self, "Save Build Configuration",
383                                        f"Save these Build options in {file.path.name} as:", text=suggestion)
384        name = name.strip()
385        if not ok or not name:
386            return False
387        if name == NEW_CONFIGURATION:
388            QMessageBox.warning(self, "Save Build Configuration", f"“{NEW_CONFIGURATION}” is taken: choose "
389                                "another name.")
390            return False
391        if self._configuration(name) is not None and name != self._editing and QMessageBox.question(
392                self, "Save Build Configuration",
393                f"{file.path.name} already has a configuration named “{name}”. Replace it?") != QMessageBox.Yes:
394            return False
395        if not self._write_configuration(self.current_options(name)):
396            return False
397        self._editing = ""  # (saved: under another name, the one edited is left as it was in the file)
398        self._fill_config_combo(name)
399        return True
400
401    def _edit_clicked(self):
402        if self._editing:
403            self.cancel_editing()
404        else:
405            self.edit_configuration()
406
407    def edit_configuration(self):
408        """Edit: the saved configuration's options, editable (Save puts them back)."""
409        if not self.options_locked():
410            return
411        self._editing = self.config_combo.currentText()
412        self._set_options_locked(False)
413        self._update_config_ui()
414
415    def cancel_editing(self):
416        """Cancel: the configuration as saved, read-only again (changes dropped)."""
417        if not self._editing:
418            return
419        name, self._editing = self._editing, ""
420        config = self._configuration(name)
421        if config is None:  # (gone from the file while it was edited)
422            self._fill_config_combo()
423            return
424        self._show_options(config)
425        self._set_options_locked(True)
426        self._update_config_ui()
427
428    def maybe_save_configuration(self) -> bool:
429        """Before leaving a configuration being edited with unsaved changes
430        (choosing another, opening another project or file, quitting): Save,
431        Discard (it's shown as saved again: what follows may not happen) or
432        Cancel. False: Cancel (or saving failed): stay."""
433        if not self.has_unsaved_configuration():
434            return True
435        answer = QMessageBox.question(
436            self, "Unsaved Build Configuration",
437            f"Save the changes to the build configuration “{self._editing}” in {self.config_path.name}?",
438            QMessageBox.Save | QMessageBox.Discard | QMessageBox.Cancel, QMessageBox.Save)
439        if answer == QMessageBox.Cancel:
440            return False
441        if answer == QMessageBox.Save:
442            name = self._editing
443            if not self._write_configuration(self.current_options(name)):
444                return False
445        else:
446            self.cancel_editing()
447        return True
448
449    def edit_configuration_as_new(self):
450        """⋯ → Edit as New Configuration: the configuration's options (as
451        shown, with any changes being made), to change, as New
452        configuration's (they replace what it had)."""
453        if not self.config_shown():
454            return
455        self._editing = ""  # (the changes go to New configuration: nothing to ask)
456        self._free_options = None  # (what's shown stays, editable)
457        self.select_configuration("")
458        self._save_features()
459
460    def delete_configuration(self):
461        if not self.config_shown() or self.config_path is None:
462            return
463        name = self.config_combo.currentText()
464        if QMessageBox.question(self, "Delete Build Configuration",
465                                f"Delete the configuration “{name}” from {self.config_path.name}?") \
466                != QMessageBox.Yes:
467            return
468        try:
469            self.config_file.remove(name)
470        except (OSError, bc.ConfigError) as exc:
471            QMessageBox.warning(self, "Delete Build Configuration", f"Couldn't change {self.config_path}:\n\n{exc}")
472            return
473        self._editing = ""
474        self.select_configuration("")
475        self._fill_config_combo()
476
477    def browse_configuration(self):
478        start = str(self.manifest_path.parent) if self.manifest_path else str(Path.home())
479        filename, _ = QFileDialog.getOpenFileName(self, "Open Build Configuration", start,
480                                                  "VibeFPM configurations (*.json);;All files (*)")
481        if filename:
482            self.open_configuration(filename)
483
484    def open_configuration(self, filename: str | Path):
485        """Open a configuration file: its project (the fpm.toml beside it),
486        if it isn't the one open, and its first configuration."""
487        path = Path(filename).resolve()
488        manifest = bc.manifest_for(path)
489        if manifest is None:
490            QMessageBox.warning(self, "Open Build Configuration",
491                                f"There's no fpm.toml beside {path.name}: a configuration file goes in the "
492                                "project's folder, next to its fpm.toml.")
493            return
494        opened = bc.ConfigFile(path).read()
495        if opened.error:
496            QMessageBox.warning(self, "Open Build Configuration", opened.error)
497            return
498        if not self.maybe_save_configuration():
499            return
500        manifest = manifest.resolve()
501        key = project_key(manifest, "build_config_file")
502        before = self.settings.app.value(key, "")
503        self._remember_config_file(manifest, path)  # (the file the project is opened with)
504        if manifest != self.manifest_path:
505            self.open_manifest(manifest)
506            if self.manifest_path != manifest:  # (not opened: Cancel, or fpm running)
507                self.settings.app.setValue(key, before)
508                return
509        elif path != self.config_path:
510            self._switch_config_file(manifest, force=True)
511            self._load_configurations()
512        configs = self.configurations
513        self._fill_config_combo(configs[0].name if configs else "")
514        self.statusBar().showMessage(f"{path.name}: {len(configs)} configuration"
515                                     f"{'s' * (len(configs) != 1)}", 5000)
516
517    def _show_config_file(self):
518        if self.config_path and self.config_path.is_file():
519            self.open_file(str(self.config_path), force_editor=True)
NEW_CONFIGURATION = 'New configuration'
class ConfigsMixin:
 43class ConfigsMixin:
 44    """Build configurations (a part of `vibefpmgui.main_window.MainWindow`)."""
 45
 46    _free_options: bc.Configuration | None = None  # New configuration's options, while a saved one is shown
 47    _editing = ""  # the saved configuration being edited (its options editable), if any
 48    _listed_file: bc.ConfigFile | None = None  # the file the list is of
 49
 50    # (The project's configuration file: config_file, see vibefpmgui.window.options.)
 51    @property
 52    def configurations(self) -> list[bc.Configuration]:
 53        return self.config_file.configurations if self.config_file is not None else []
 54
 55    @property
 56    def _config_error(self) -> str:
 57        """Why the configuration file can't be read ("" if it can)."""
 58        return self.config_file.error if self.config_file is not None else ""
 59
 60    def _build_config_row(self, form):
 61        """The Configuration row (the first of Build options) and the
 62        read-only note under it."""
 63        self.config_combo = QComboBox()
 64        self.config_combo.addItem(NEW_CONFIGURATION)
 65        # (As narrow as the panel gets: not as wide as the longest name.)
 66        self.config_combo.setSizeAdjustPolicy(QComboBox.AdjustToMinimumContentsLengthWithIcon)
 67        self.config_combo.setMinimumContentsLength(6)
 68        self.config_combo.currentIndexChanged.connect(self._config_chosen)
 69        self.config_edit_button = QToolButton()
 70        self.config_edit_button.setText("Edit")
 71        self.config_edit_button.clicked.connect(self._edit_clicked)
 72        self.config_save_button = QToolButton()
 73        self.config_save_button.setText("Save…")
 74        self.config_save_button.clicked.connect(self.save_configuration)
 75        more = QToolButton()
 76        more.setText("⋯")
 77        more.setToolTip("More: save as, edit a copy, delete, open a configuration file")
 78        menu = QMenu(more)
 79        self.config_save_as_action = menu.addAction("Save As…", self.save_configuration_as)
 80        self.config_save_as_action.setToolTip("Save the options shown as a configuration under another name")
 81        self.config_edit_action = menu.addAction("Edit as New Configuration", self.edit_configuration_as_new)
 82        self.config_edit_action.setToolTip("Copy this configuration's options to New configuration, to change "
 83                                           "them (and Save…, under its name or another)")
 84        self.config_delete_action = menu.addAction("Delete Configuration…", self.delete_configuration)
 85        menu.addSeparator()
 86        menu.addAction("Open Configuration File…", self.browse_configuration)
 87        self.config_show_action = menu.addAction("Show Configuration File in Editor", self._show_config_file)
 88        menu.setToolTipsVisible(True)
 89        # (Opened by hand: a button's own menu adds an arrow beside the icon.)
 90        more.clicked.connect(lambda: menu.popup(more.mapToGlobal(more.rect().bottomLeft())))
 91        self._config_more_menu = menu
 92        self.config_more_button = more
 93        # Icon buttons, as the Debug and Git panels' (the texts kept, for the tooltips' and
 94        # screen readers' sake; the icons: see _draw_config_icons).
 95        for button in (self.config_edit_button, self.config_save_button, more):
 96            button.setToolButtonStyle(Qt.ToolButtonIconOnly)
 97            button.setIconSize(QSize(18, 18))
 98            button.setAutoRaise(True)
 99        row = QHBoxLayout()
100        row.addWidget(self.config_combo, 1)
101        row.addWidget(self.config_edit_button)
102        row.addWidget(self.config_save_button)
103        row.addWidget(more)
104        form.addRow(row)  # (the panel's whole width: with a label, it made the panel wider than its fields)
105        self.config_note = QLabel()
106        self.config_note.setWordWrap(True)
107        self.config_note.hide()
108        form.addRow(self.config_note)
109        self._update_config_ui()
110
111    # ------------------------------------------------------------ options
112    def _option_fields(self) -> list:
113        """The fields a configuration sets (not the fpm executable)."""
114        return [self.profile_combo, self.compiler_edit, self.flag_edit, self.target_edit, self.extra_edit,
115                self.runner_args_edit, self.test_jobs_spin, self.coverage_check, self.verbose_check]
116
117    def _watch_option_fields(self):
118        """(After the fields are made:) a change shows whether there are unsaved ones."""
119        for widget in self._option_fields():
120            for signal in ("textChanged", "valueChanged", "toggled", "currentTextChanged"):
121                if hasattr(widget, signal):
122                    getattr(widget, signal).connect(self._options_changed)
123                    break
124
125    def _options_changed(self, *_):
126        if self._editing:
127            self._update_config_ui()
128
129    def config_shown(self) -> bool:
130        """A saved configuration is shown (read-only, or being edited)."""
131        return self.config_combo.currentIndex() > 0
132
133    def options_locked(self) -> bool:
134        """A saved configuration is shown, read-only (not being edited)."""
135        return self.config_shown() and not self._editing
136
137    def has_unsaved_configuration(self) -> bool:
138        """The configuration being edited has changes not in the file."""
139        return bool(self._editing) and self.current_options(self._editing) != self._configuration(self._editing)
140
141    def current_options(self, name: str = "") -> bc.Configuration:
142        """The Build options shown, as a configuration named ``name``."""
143        return bc.Configuration(
144            name, profile=self.profile_combo.currentText() or bc.DEFAULT_PROFILE,
145            features=list(self.selected_features), compiler=self.compiler_edit.text().strip(),
146            flags=self.flag_edit.text().strip(), target=self.target_edit.text().strip(),
147            extra_args=self.extra_edit.text().strip(), program_args=self.runner_args_edit.text().strip(),
148            parallel_tests=self.test_jobs_spin.value(), coverage=self.coverage_check.isChecked(),
149            verbose=self.verbose_check.isChecked())
150
151    def free_options(self) -> bc.Configuration:
152        """New configuration's options (those to remember), even while a saved one is shown."""
153        return self._free_options if self._free_options is not None else self.current_options()
154
155    def _show_options(self, config: bc.Configuration):
156        """Fill the fields from ``config``."""
157        combo = self.profile_combo
158        if combo.findText(config.profile) < 0:  # (a profile fpm.toml no longer has: shown as asked for)
159            combo.addItem(config.profile)
160        combo.setCurrentText(config.profile)
161        self.compiler_edit.setText(config.compiler)
162        self.flag_edit.setText(config.flags)
163        self.target_edit.setText(config.target)
164        self.extra_edit.setText(config.extra_args)
165        self.runner_args_edit.setText(config.program_args)
166        self.test_jobs_spin.setValue(config.parallel_tests)
167        self.coverage_check.setChecked(config.coverage)
168        self.verbose_check.setChecked(config.verbose)
169        self.selected_features = list(config.features)
170        self._update_features_ui()
171
172    def _set_options_locked(self, locked: bool):
173        """Greyed out (read-only fields looked the same as the others)."""
174        for widget in self._option_fields():
175            if widget is not self.profile_combo:  # (its state: see _update_features_ui)
176                widget.setEnabled(not locked)
177        self._update_features_ui()
178
179    # ------------------------------------------------------------ choosing
180    def _config_chosen(self, index: int):
181        if self._editing:
182            editing = self._editing
183            if not self.maybe_save_configuration():
184                self.config_combo.blockSignals(True)  # (Cancel: still on it, its changes kept)
185                self.config_combo.setCurrentIndex(self._index_of(editing))
186                self.config_combo.blockSignals(False)
187                return
188            self._editing = ""
189        if index <= 0:
190            if self._free_options is not None:
191                free, self._free_options = self._free_options, None
192                self._show_options(free)
193        else:
194            config = self._configuration(self.config_combo.itemText(index))
195            if config is None:
196                return
197            if self._free_options is None:
198                self._free_options = self.current_options()
199            self._show_options(config)
200        self._set_options_locked(index > 0)
201        if self.manifest_path is not None:
202            self.settings.setValue("build_configuration", self.config_combo.currentText() if index > 0 else "")
203        self._update_config_ui()
204
205    def _configuration(self, name: str) -> bc.Configuration | None:
206        return next((c for c in self.configurations if c.name == name), None)
207
208    def _index_of(self, name: str) -> int:
209        """The list's item of the saved configuration named ``name`` (0: New
210        configuration, if there's none; one may be named like it)."""
211        combo = self.config_combo
212        return next((i for i in range(1, combo.count()) if name and combo.itemText(i) == name), 0)
213
214    def select_configuration(self, name: str):
215        """Show the configuration named ``name`` ("": New configuration)."""
216        self.config_combo.setCurrentIndex(self._index_of(name))
217
218    # ------------------------------------------------------------ the file
219    def _load_configurations(self, reread: bool = True):
220        """The list of the project's configurations (the project was opened
221        or reloaded): its configurations read again (not for a file just
222        opened, or ``reread`` False: just read), the one shown kept (for a
223        file just opened, the one shown last time); one being edited keeps
224        its changes."""
225        file = self.config_file
226        if file is None or self.manifest_path is None:
227            return
228        first = file is not self._listed_file
229        if reread and not first:
230            file.reread_configurations()  # (not its options: they're the window's until written)
231        self._listed_file = file
232        if self._editing and not first:
233            self._refill_items(self._editing)
234            return
235        if first:
236            self._editing = ""  # (asked about before the project changed: see maybe_save_configuration)
237            wanted = self.settings.value("build_configuration", "") or ""
238        else:
239            wanted = self.config_combo.currentText() if self.options_locked() else ""
240        if self._config_error:
241            self.statusBar().showMessage(f"Build configurations: {self._config_error}", 8000)
242        self._fill_config_combo(wanted)
243
244    def reload_configurations(self):
245        """The configuration file was saved in the editor: its options, if
246        they were changed there, applied, and its configurations listed again
247        (the one shown kept: with its options as the file has them now,
248        unless it's being edited: its changes are kept)."""
249        if self.config_file is None or self.manifest_path is None:
250            return
251        self.reload_config_file()  # (it reads it all)
252        self._load_configurations(reread=False)
253        if self._config_error:
254            return  # (the list says why; it's empty until the file is fixed)
255        count = len(self.configurations)
256        self.statusBar().showMessage(f"Build configurations read again from {self.config_path.name}: {count} "
257                                     f"configuration{'s' * (count != 1)}", 5000)
258
259    def _fill_config_combo(self, wanted: str = ""):
260        """The list again, showing ``wanted`` (New configuration if it's gone):
261        back to New configuration first (its options restored), then to
262        ``wanted`` (its options read again: the file may have changed)."""
263        self.select_configuration("")
264        self._refill_items()
265        if wanted and self._configuration(wanted) is not None:
266            self.select_configuration(wanted)
267        self._update_config_ui()
268
269    def _refill_items(self, shown: str = ""):
270        """The list's names again, ``shown`` chosen (no options changed)."""
271        combo = self.config_combo
272        combo.blockSignals(True)
273        while combo.count() > 1:
274            combo.removeItem(1)
275        names = [config.name for config in self.configurations]
276        combo.addItems(names + ([shown] if shown and shown not in names else []))  # (being edited: kept)
277        combo.setCurrentIndex(self._index_of(shown))
278        combo.blockSignals(False)
279        self._update_config_ui()
280
281    def _draw_config_icons(self):
282        """The Configuration row's icons in the theme's text color (again when
283        it changes: see window.view): Edit a pencil (Cancel, while editing, an
284        ×), Save a disk, ⋯ three lines."""
285        color = QApplication.palette().color(QPalette.WindowText)
286        self.config_edit_button.setIcon(make_icon("close" if self._editing else "pencil", color))
287        self.config_save_button.setIcon(make_icon("save", color))
288        self.config_more_button.setIcon(make_icon("menu", color))
289
290    def _update_config_ui(self):
291        locked, editing = self.options_locked(), bool(self._editing)
292        unsaved = self.has_unsaved_configuration()
293        has_project = getattr(self, "manifest_path", None) is not None
294        self.config_edit_button.setText("Cancel" if editing else "Edit")
295        self._draw_config_icons()
296        self.config_edit_button.setEnabled(locked or editing)
297        self.config_edit_button.setToolTip(
298            "Stop editing: the configuration's options as saved" if editing else
299            "Change this configuration's options (then Save them under its name)" if locked else
300            "Choose a saved configuration to edit it")
301        self.config_save_button.setText("Save" if editing else "Save…")
302        self.config_save_button.setEnabled(has_project and (unsaved if editing else not locked))
303        self.config_save_button.setToolTip(
304            f"Save the changes to “{self._editing}”" if editing else
305            f"Save these options as a configuration in the project's {bc.FILE_NAME} (beside fpm.toml)")
306        self.config_save_as_action.setEnabled(has_project)
307        self.config_edit_action.setEnabled(self.config_shown())
308        self.config_delete_action.setEnabled(self.config_shown())
309        self.config_show_action.setEnabled(bool(self.config_path and self.config_path.is_file()))
310        where = self.config_path.name if self.config_path else bc.FILE_NAME
311        if self._config_error:
312            tip = f"⚠ {self._config_error}"
313            if self.options_in_use():  # (read when the project was opened: kept, not written over)
314                tip += "\nThe project's options aren't saved in it until it's fixed."
315        elif self.config_path and self.configurations:
316            tip = f"The configurations in {self.config_path}"
317        else:
318            tip = f"No saved configurations yet: Save… adds the options shown to {where}, beside fpm.toml"
319        self.config_combo.setToolTip(tip)
320        if locked:
321            self.config_note.setText(f"<span style='color:gray'>Read-only, from {where}.</span>")
322            self.config_note.setToolTip("Edit to change its options (then Save), or choose "
323                                        f"{NEW_CONFIGURATION}.")
324        elif editing:
325            self.config_note.setText(f"<span style='color:#e8590c'>Editing; changes not saved in {where}.</span>"
326                                     if unsaved else f"<span style='color:gray'>Editing (from {where}).</span>")
327            self.config_note.setToolTip("Save puts the changes in the file; Cancel goes back to it as saved.")
328        self.config_note.setVisible(locked or editing)
329        self.update_window_title()  # (the configuration chosen is in it)
330
331    def _remember_config_file(self, manifest: Path, path: Path):
332        """The configuration file the project uses from now on."""
333        self.settings.setValue(project_key(manifest, "build_config_file"),
334                               "" if path == bc.default_path(manifest) else str(path))
335
336    # ------------------------------------------------------------ commands
337    def save_configuration(self) -> bool:
338        """Build options → Save (File → Save Build Configuration…): the
339        configuration being edited, under its name; otherwise Save As…."""
340        if not self._editing:
341            return self.save_configuration_as()
342        name = self._editing
343        if not self._write_configuration(self.current_options(name)):
344            return False
345        self._editing = ""
346        self._fill_config_combo(name)
347        return True
348
349    def _config_file_for_saving(self) -> bc.ConfigFile | None:
350        """The file to save in (None: it can't be read, so isn't written over)."""
351        file = self.config_file
352        if file is None:
353            return None
354        if file.error and file.exists():
355            QMessageBox.warning(self, "Save Build Configuration",
356                                f"{file.error}\n\nFix the file (or move it away) to save configurations in it.")
357            return None
358        return file
359
360    def _write_configuration(self, config: bc.Configuration) -> bool:
361        file = self._config_file_for_saving()
362        if file is None:
363            return False
364        try:
365            file.save(config)
366        except (OSError, bc.ConfigError) as exc:
367            QMessageBox.warning(self, "Save Build Configuration", f"Couldn't save {file.path}:\n\n{exc}")
368            return False
369        self._remember_config_file(self.manifest_path, file.path)
370        self._config_file_saved()  # (a file just made: the project's options go in it from now on)
371        self.statusBar().showMessage(f"Saved build configuration “{config.name}” in {file.path.name}", 5000)
372        return True
373
374    def save_configuration_as(self) -> bool:
375        """Save… / ⋯ → Save As…: what's shown, as a configuration in the
376        project's file under a name asked for, chosen from then on."""
377        if self.manifest_path is None:
378            return False
379        file = self._config_file_for_saving()
380        if file is None:
381            return False
382        suggestion = self.config_combo.currentText() if self.config_shown() else ""
383        name, ok = QInputDialog.getText(self, "Save Build Configuration",
384                                        f"Save these Build options in {file.path.name} as:", text=suggestion)
385        name = name.strip()
386        if not ok or not name:
387            return False
388        if name == NEW_CONFIGURATION:
389            QMessageBox.warning(self, "Save Build Configuration", f"“{NEW_CONFIGURATION}” is taken: choose "
390                                "another name.")
391            return False
392        if self._configuration(name) is not None and name != self._editing and QMessageBox.question(
393                self, "Save Build Configuration",
394                f"{file.path.name} already has a configuration named “{name}”. Replace it?") != QMessageBox.Yes:
395            return False
396        if not self._write_configuration(self.current_options(name)):
397            return False
398        self._editing = ""  # (saved: under another name, the one edited is left as it was in the file)
399        self._fill_config_combo(name)
400        return True
401
402    def _edit_clicked(self):
403        if self._editing:
404            self.cancel_editing()
405        else:
406            self.edit_configuration()
407
408    def edit_configuration(self):
409        """Edit: the saved configuration's options, editable (Save puts them back)."""
410        if not self.options_locked():
411            return
412        self._editing = self.config_combo.currentText()
413        self._set_options_locked(False)
414        self._update_config_ui()
415
416    def cancel_editing(self):
417        """Cancel: the configuration as saved, read-only again (changes dropped)."""
418        if not self._editing:
419            return
420        name, self._editing = self._editing, ""
421        config = self._configuration(name)
422        if config is None:  # (gone from the file while it was edited)
423            self._fill_config_combo()
424            return
425        self._show_options(config)
426        self._set_options_locked(True)
427        self._update_config_ui()
428
429    def maybe_save_configuration(self) -> bool:
430        """Before leaving a configuration being edited with unsaved changes
431        (choosing another, opening another project or file, quitting): Save,
432        Discard (it's shown as saved again: what follows may not happen) or
433        Cancel. False: Cancel (or saving failed): stay."""
434        if not self.has_unsaved_configuration():
435            return True
436        answer = QMessageBox.question(
437            self, "Unsaved Build Configuration",
438            f"Save the changes to the build configuration “{self._editing}” in {self.config_path.name}?",
439            QMessageBox.Save | QMessageBox.Discard | QMessageBox.Cancel, QMessageBox.Save)
440        if answer == QMessageBox.Cancel:
441            return False
442        if answer == QMessageBox.Save:
443            name = self._editing
444            if not self._write_configuration(self.current_options(name)):
445                return False
446        else:
447            self.cancel_editing()
448        return True
449
450    def edit_configuration_as_new(self):
451        """⋯ → Edit as New Configuration: the configuration's options (as
452        shown, with any changes being made), to change, as New
453        configuration's (they replace what it had)."""
454        if not self.config_shown():
455            return
456        self._editing = ""  # (the changes go to New configuration: nothing to ask)
457        self._free_options = None  # (what's shown stays, editable)
458        self.select_configuration("")
459        self._save_features()
460
461    def delete_configuration(self):
462        if not self.config_shown() or self.config_path is None:
463            return
464        name = self.config_combo.currentText()
465        if QMessageBox.question(self, "Delete Build Configuration",
466                                f"Delete the configuration “{name}” from {self.config_path.name}?") \
467                != QMessageBox.Yes:
468            return
469        try:
470            self.config_file.remove(name)
471        except (OSError, bc.ConfigError) as exc:
472            QMessageBox.warning(self, "Delete Build Configuration", f"Couldn't change {self.config_path}:\n\n{exc}")
473            return
474        self._editing = ""
475        self.select_configuration("")
476        self._fill_config_combo()
477
478    def browse_configuration(self):
479        start = str(self.manifest_path.parent) if self.manifest_path else str(Path.home())
480        filename, _ = QFileDialog.getOpenFileName(self, "Open Build Configuration", start,
481                                                  "VibeFPM configurations (*.json);;All files (*)")
482        if filename:
483            self.open_configuration(filename)
484
485    def open_configuration(self, filename: str | Path):
486        """Open a configuration file: its project (the fpm.toml beside it),
487        if it isn't the one open, and its first configuration."""
488        path = Path(filename).resolve()
489        manifest = bc.manifest_for(path)
490        if manifest is None:
491            QMessageBox.warning(self, "Open Build Configuration",
492                                f"There's no fpm.toml beside {path.name}: a configuration file goes in the "
493                                "project's folder, next to its fpm.toml.")
494            return
495        opened = bc.ConfigFile(path).read()
496        if opened.error:
497            QMessageBox.warning(self, "Open Build Configuration", opened.error)
498            return
499        if not self.maybe_save_configuration():
500            return
501        manifest = manifest.resolve()
502        key = project_key(manifest, "build_config_file")
503        before = self.settings.app.value(key, "")
504        self._remember_config_file(manifest, path)  # (the file the project is opened with)
505        if manifest != self.manifest_path:
506            self.open_manifest(manifest)
507            if self.manifest_path != manifest:  # (not opened: Cancel, or fpm running)
508                self.settings.app.setValue(key, before)
509                return
510        elif path != self.config_path:
511            self._switch_config_file(manifest, force=True)
512            self._load_configurations()
513        configs = self.configurations
514        self._fill_config_combo(configs[0].name if configs else "")
515        self.statusBar().showMessage(f"{path.name}: {len(configs)} configuration"
516                                     f"{'s' * (len(configs) != 1)}", 5000)
517
518    def _show_config_file(self):
519        if self.config_path and self.config_path.is_file():
520            self.open_file(str(self.config_path), force_editor=True)

Build configurations (a part of vibefpmgui.main_window.MainWindow).

configurations: list[vibefpmgui.build_configs.Configuration]
51    @property
52    def configurations(self) -> list[bc.Configuration]:
53        return self.config_file.configurations if self.config_file is not None else []
def config_shown(self) -> bool:
129    def config_shown(self) -> bool:
130        """A saved configuration is shown (read-only, or being edited)."""
131        return self.config_combo.currentIndex() > 0

A saved configuration is shown (read-only, or being edited).

def options_locked(self) -> bool:
133    def options_locked(self) -> bool:
134        """A saved configuration is shown, read-only (not being edited)."""
135        return self.config_shown() and not self._editing

A saved configuration is shown, read-only (not being edited).

def has_unsaved_configuration(self) -> bool:
137    def has_unsaved_configuration(self) -> bool:
138        """The configuration being edited has changes not in the file."""
139        return bool(self._editing) and self.current_options(self._editing) != self._configuration(self._editing)

The configuration being edited has changes not in the file.

def current_options(self, name: str = '') -> vibefpmgui.build_configs.Configuration:
141    def current_options(self, name: str = "") -> bc.Configuration:
142        """The Build options shown, as a configuration named ``name``."""
143        return bc.Configuration(
144            name, profile=self.profile_combo.currentText() or bc.DEFAULT_PROFILE,
145            features=list(self.selected_features), compiler=self.compiler_edit.text().strip(),
146            flags=self.flag_edit.text().strip(), target=self.target_edit.text().strip(),
147            extra_args=self.extra_edit.text().strip(), program_args=self.runner_args_edit.text().strip(),
148            parallel_tests=self.test_jobs_spin.value(), coverage=self.coverage_check.isChecked(),
149            verbose=self.verbose_check.isChecked())

The Build options shown, as a configuration named name.

def free_options(self) -> vibefpmgui.build_configs.Configuration:
151    def free_options(self) -> bc.Configuration:
152        """New configuration's options (those to remember), even while a saved one is shown."""
153        return self._free_options if self._free_options is not None else self.current_options()

New configuration's options (those to remember), even while a saved one is shown.

def select_configuration(self, name: str):
214    def select_configuration(self, name: str):
215        """Show the configuration named ``name`` ("": New configuration)."""
216        self.config_combo.setCurrentIndex(self._index_of(name))

Show the configuration named name ("": New configuration).

def reload_configurations(self):
244    def reload_configurations(self):
245        """The configuration file was saved in the editor: its options, if
246        they were changed there, applied, and its configurations listed again
247        (the one shown kept: with its options as the file has them now,
248        unless it's being edited: its changes are kept)."""
249        if self.config_file is None or self.manifest_path is None:
250            return
251        self.reload_config_file()  # (it reads it all)
252        self._load_configurations(reread=False)
253        if self._config_error:
254            return  # (the list says why; it's empty until the file is fixed)
255        count = len(self.configurations)
256        self.statusBar().showMessage(f"Build configurations read again from {self.config_path.name}: {count} "
257                                     f"configuration{'s' * (count != 1)}", 5000)

The configuration file was saved in the editor: its options, if they were changed there, applied, and its configurations listed again (the one shown kept: with its options as the file has them now, unless it's being edited: its changes are kept).

def save_configuration(self) -> bool:
337    def save_configuration(self) -> bool:
338        """Build options → Save (File → Save Build Configuration…): the
339        configuration being edited, under its name; otherwise Save As…."""
340        if not self._editing:
341            return self.save_configuration_as()
342        name = self._editing
343        if not self._write_configuration(self.current_options(name)):
344            return False
345        self._editing = ""
346        self._fill_config_combo(name)
347        return True

Build options → Save (File → Save Build Configuration…): the configuration being edited, under its name; otherwise Save As….

def save_configuration_as(self) -> bool:
374    def save_configuration_as(self) -> bool:
375        """Save… / ⋯ → Save As…: what's shown, as a configuration in the
376        project's file under a name asked for, chosen from then on."""
377        if self.manifest_path is None:
378            return False
379        file = self._config_file_for_saving()
380        if file is None:
381            return False
382        suggestion = self.config_combo.currentText() if self.config_shown() else ""
383        name, ok = QInputDialog.getText(self, "Save Build Configuration",
384                                        f"Save these Build options in {file.path.name} as:", text=suggestion)
385        name = name.strip()
386        if not ok or not name:
387            return False
388        if name == NEW_CONFIGURATION:
389            QMessageBox.warning(self, "Save Build Configuration", f"“{NEW_CONFIGURATION}” is taken: choose "
390                                "another name.")
391            return False
392        if self._configuration(name) is not None and name != self._editing and QMessageBox.question(
393                self, "Save Build Configuration",
394                f"{file.path.name} already has a configuration named “{name}”. Replace it?") != QMessageBox.Yes:
395            return False
396        if not self._write_configuration(self.current_options(name)):
397            return False
398        self._editing = ""  # (saved: under another name, the one edited is left as it was in the file)
399        self._fill_config_combo(name)
400        return True

Save… / ⋯ → Save As…: what's shown, as a configuration in the project's file under a name asked for, chosen from then on.

def edit_configuration(self):
408    def edit_configuration(self):
409        """Edit: the saved configuration's options, editable (Save puts them back)."""
410        if not self.options_locked():
411            return
412        self._editing = self.config_combo.currentText()
413        self._set_options_locked(False)
414        self._update_config_ui()

Edit: the saved configuration's options, editable (Save puts them back).

def cancel_editing(self):
416    def cancel_editing(self):
417        """Cancel: the configuration as saved, read-only again (changes dropped)."""
418        if not self._editing:
419            return
420        name, self._editing = self._editing, ""
421        config = self._configuration(name)
422        if config is None:  # (gone from the file while it was edited)
423            self._fill_config_combo()
424            return
425        self._show_options(config)
426        self._set_options_locked(True)
427        self._update_config_ui()

Cancel: the configuration as saved, read-only again (changes dropped).

def maybe_save_configuration(self) -> bool:
429    def maybe_save_configuration(self) -> bool:
430        """Before leaving a configuration being edited with unsaved changes
431        (choosing another, opening another project or file, quitting): Save,
432        Discard (it's shown as saved again: what follows may not happen) or
433        Cancel. False: Cancel (or saving failed): stay."""
434        if not self.has_unsaved_configuration():
435            return True
436        answer = QMessageBox.question(
437            self, "Unsaved Build Configuration",
438            f"Save the changes to the build configuration “{self._editing}” in {self.config_path.name}?",
439            QMessageBox.Save | QMessageBox.Discard | QMessageBox.Cancel, QMessageBox.Save)
440        if answer == QMessageBox.Cancel:
441            return False
442        if answer == QMessageBox.Save:
443            name = self._editing
444            if not self._write_configuration(self.current_options(name)):
445                return False
446        else:
447            self.cancel_editing()
448        return True

Before leaving a configuration being edited with unsaved changes (choosing another, opening another project or file, quitting): Save, Discard (it's shown as saved again: what follows may not happen) or Cancel. False: Cancel (or saving failed): stay.

def edit_configuration_as_new(self):
450    def edit_configuration_as_new(self):
451        """⋯ → Edit as New Configuration: the configuration's options (as
452        shown, with any changes being made), to change, as New
453        configuration's (they replace what it had)."""
454        if not self.config_shown():
455            return
456        self._editing = ""  # (the changes go to New configuration: nothing to ask)
457        self._free_options = None  # (what's shown stays, editable)
458        self.select_configuration("")
459        self._save_features()

⋯ → Edit as New Configuration: the configuration's options (as shown, with any changes being made), to change, as New configuration's (they replace what it had).

def delete_configuration(self):
461    def delete_configuration(self):
462        if not self.config_shown() or self.config_path is None:
463            return
464        name = self.config_combo.currentText()
465        if QMessageBox.question(self, "Delete Build Configuration",
466                                f"Delete the configuration “{name}” from {self.config_path.name}?") \
467                != QMessageBox.Yes:
468            return
469        try:
470            self.config_file.remove(name)
471        except (OSError, bc.ConfigError) as exc:
472            QMessageBox.warning(self, "Delete Build Configuration", f"Couldn't change {self.config_path}:\n\n{exc}")
473            return
474        self._editing = ""
475        self.select_configuration("")
476        self._fill_config_combo()
def browse_configuration(self):
478    def browse_configuration(self):
479        start = str(self.manifest_path.parent) if self.manifest_path else str(Path.home())
480        filename, _ = QFileDialog.getOpenFileName(self, "Open Build Configuration", start,
481                                                  "VibeFPM configurations (*.json);;All files (*)")
482        if filename:
483            self.open_configuration(filename)
def open_configuration(self, filename: str | pathlib.Path):
485    def open_configuration(self, filename: str | Path):
486        """Open a configuration file: its project (the fpm.toml beside it),
487        if it isn't the one open, and its first configuration."""
488        path = Path(filename).resolve()
489        manifest = bc.manifest_for(path)
490        if manifest is None:
491            QMessageBox.warning(self, "Open Build Configuration",
492                                f"There's no fpm.toml beside {path.name}: a configuration file goes in the "
493                                "project's folder, next to its fpm.toml.")
494            return
495        opened = bc.ConfigFile(path).read()
496        if opened.error:
497            QMessageBox.warning(self, "Open Build Configuration", opened.error)
498            return
499        if not self.maybe_save_configuration():
500            return
501        manifest = manifest.resolve()
502        key = project_key(manifest, "build_config_file")
503        before = self.settings.app.value(key, "")
504        self._remember_config_file(manifest, path)  # (the file the project is opened with)
505        if manifest != self.manifest_path:
506            self.open_manifest(manifest)
507            if self.manifest_path != manifest:  # (not opened: Cancel, or fpm running)
508                self.settings.app.setValue(key, before)
509                return
510        elif path != self.config_path:
511            self._switch_config_file(manifest, force=True)
512            self._load_configurations()
513        configs = self.configurations
514        self._fill_config_combo(configs[0].name if configs else "")
515        self.statusBar().showMessage(f"{path.name}: {len(configs)} configuration"
516                                     f"{'s' * (len(configs) != 1)}", 5000)

Open a configuration file: its project (the fpm.toml beside it), if it isn't the one open, and its first configuration.