Coverage for vibefpmgui/window/configs.py: 86%
355 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-11-01 20:51 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-11-01 20:51 +0000
1"""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.)
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"""
17from __future__ import annotations
19from pathlib import Path
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)
35from .. import build_configs as bc
36from ..icons import make_icon
37from .options import project_key
39NEW_CONFIGURATION = "New configuration"
42class ConfigsMixin:
43 """Build configurations (a part of `vibefpmgui.main_window.MainWindow`)."""
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
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 []
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 ""
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()
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]
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"): 119 ↛ 118line 119 didn't jump to line 118 because the loop on line 119 didn't complete
120 if hasattr(widget, signal):
121 getattr(widget, signal).connect(self._options_changed)
122 break
124 def _options_changed(self, *_):
125 if self._editing:
126 self._update_config_ui()
128 def config_shown(self) -> bool:
129 """A saved configuration is shown (read-only, or being edited)."""
130 return self.config_combo.currentIndex() > 0
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
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)
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())
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()
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) 157 ↛ 158line 157 didn't jump to line 158 because the condition on line 157 was never true
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()
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()
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: 194 ↛ 195line 194 didn't jump to line 195 because the condition on line 194 was never true
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: 200 ↛ 202line 200 didn't jump to line 202 because the condition on line 200 was always true
201 self.settings.setValue("build_configuration", self.config_combo.currentText() if index > 0 else "")
202 self._update_config_ui()
204 def _configuration(self, name: str) -> bc.Configuration | None:
205 return next((c for c in self.configurations if c.name == name), None)
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)
213 def select_configuration(self, name: str):
214 """Show the configuration named ``name`` ("": New configuration)."""
215 self.config_combo.setCurrentIndex(self._index_of(name))
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: 225 ↛ 226line 225 didn't jump to line 226 because the condition on line 225 was never true
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)
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: 248 ↛ 249line 248 didn't jump to line 249 because the condition on line 248 was never true
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)
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()
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()
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))
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)
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))
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)): 342 ↛ 343line 342 didn't jump to line 343 because the condition on line 342 was never true
343 return False
344 self._editing = ""
345 self._fill_config_combo(name)
346 return True
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: 351 ↛ 352line 351 didn't jump to line 352 because the condition on line 351 was never true
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
359 def _write_configuration(self, config: bc.Configuration) -> bool:
360 file = self._config_file_for_saving()
361 if file is None: 361 ↛ 362line 361 didn't jump to line 362 because the condition on line 361 was never true
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
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: 376 ↛ 377line 376 didn't jump to line 377 because the condition on line 376 was never true
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: 385 ↛ 386line 385 didn't jump to line 386 because the condition on line 385 was never true
386 return False
387 if name == NEW_CONFIGURATION: 387 ↛ 388line 387 didn't jump to line 388 because the condition on line 387 was never true
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( 391 ↛ 394line 391 didn't jump to line 394 because the condition on line 391 was never true
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)): 395 ↛ 396line 395 didn't jump to line 396 because the condition on line 395 was never true
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
401 def _edit_clicked(self):
402 if self._editing:
403 self.cancel_editing()
404 else:
405 self.edit_configuration()
407 def edit_configuration(self):
408 """Edit: the saved configuration's options, editable (Save puts them back)."""
409 if not self.options_locked(): 409 ↛ 410line 409 didn't jump to line 410 because the condition on line 409 was never true
410 return
411 self._editing = self.config_combo.currentText()
412 self._set_options_locked(False)
413 self._update_config_ui()
415 def cancel_editing(self):
416 """Cancel: the configuration as saved, read-only again (changes dropped)."""
417 if not self._editing: 417 ↛ 418line 417 didn't jump to line 418 because the condition on line 417 was never true
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) 421 ↛ 422line 421 didn't jump to line 422 because the condition on line 421 was never true
422 self._fill_config_combo()
423 return
424 self._show_options(config)
425 self._set_options_locked(True)
426 self._update_config_ui()
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)): 443 ↛ 444line 443 didn't jump to line 444 because the condition on line 443 was never true
444 return False
445 else:
446 self.cancel_editing()
447 return True
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(): 453 ↛ 454line 453 didn't jump to line 454 because the condition on line 453 was never true
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()
460 def delete_configuration(self):
461 if not self.config_shown() or self.config_path is None: 461 ↛ 462line 461 didn't jump to line 462 because the condition on line 461 was never true
462 return
463 name = self.config_combo.currentText()
464 if QMessageBox.question(self, "Delete Build Configuration", 464 ↛ 467line 464 didn't jump to line 467 because the condition on line 464 was never true
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()
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)
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: 495 ↛ 496line 495 didn't jump to line 496 because the condition on line 495 was never true
496 QMessageBox.warning(self, "Open Build Configuration", opened.error)
497 return
498 if not self.maybe_save_configuration(): 498 ↛ 499line 498 didn't jump to line 499 because the condition on line 498 was never true
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: 509 ↛ 512line 509 didn't jump to line 512 because the condition on line 509 was always true
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)
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)