Coverage for vibefpmgui/window/options.py: 95%
174 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"""The project's configuration file (`vibefpmgui.build_configs.ConfigFile`):
2which it is, and the GUI's options and New configuration's Build options in
3it (see `vibefpmgui.project_options`): used while the project is open,
4written back (when the window closes or another project is opened, if they
5changed), and applied to the window when another project is opened.
7A project whose file holds options (it has one that can be read) uses them;
8without, the app's settings as before (nothing is written in a project only
9looked at). A file made while the project is open (its first configuration
10saved) holds its options from then on.
12New configuration's Build options are one `Configuration`: the file's
13"new_configuration", or, without options, the app's settings ("profile",
14"compiler"… and the project's "features/<hash>").
15"""
17from __future__ import annotations
19import hashlib
20from pathlib import Path
22from .. import build_configs as bc
23from .. import project_options as po
24from .. import spelling
25from ..code_editor import CodeEditor
26from ..graph_view import EDGE_STYLES, LAYOUTS, THEME_CHOICES
28NEW_CONFIGURATION_KEY = "new_configuration" # (for _apply_options: New configuration's Build options)
31def project_key(manifest: Path, prefix: str) -> str:
32 """The app's settings key for a project's ``prefix`` (its session, build
33 configurations, features, test durations): "prefix/" and a short hash
34 of its fpm.toml's path. (A name, not security: SHA-1 is fine.)"""
35 digest = hashlib.sha1(str(manifest).encode(), usedforsecurity=False).hexdigest()[:16]
36 return f"{prefix}/{digest}"
39class ProjectOptionsMixin:
40 """The project's configuration file and its options (a part of
41 `vibefpmgui.main_window.MainWindow`)."""
43 config_file: bc.ConfigFile | None = None # the open project's (it may not exist yet)
44 _config_manifest: Path | None = None # the project config_file is for
46 @property
47 def config_path(self) -> Path | None:
48 return self.config_file.path if self.config_file is not None else None
50 def _project_key(self, prefix: str) -> str:
51 """The app's settings key for this project's ``prefix`` (see `project_key`)."""
52 return project_key(self.manifest_path, prefix)
54 def options_in_use(self) -> bool:
55 """The project's options are its file's (not the app's settings)."""
56 return self.settings.options is not None
58 # ------------------------------------------------------------ the file
59 def _config_file_for(self, manifest: Path) -> bc.ConfigFile:
60 """A project's configuration file, read: the one opened for it last
61 (Build options → ⋯ → Open Configuration File…), or the one beside its fpm.toml."""
62 remembered = self.settings.app.value(project_key(manifest, "build_config_file"), "") or ""
63 path = Path(remembered) if remembered and Path(remembered).is_file() else bc.default_path(manifest)
64 return bc.ConfigFile(path).read()
66 def _use_config_file(self, file: bc.ConfigFile | None, manifest: Path | None, options: dict | None = None):
67 """Use ``file`` (the project ``manifest``'s) and its options (or
68 ``options``) from now on."""
69 self.config_file, self._config_manifest = file, manifest
70 keys = {name: project_key(manifest, name) for name in po.PER_PROJECT} if manifest is not None else {}
71 if options is None and file is not None:
72 options = file.options
73 self.settings.use(options, manifest.parent if options is not None else None, keys)
75 def _config_file_saved(self):
76 """A configuration was saved: a file just made holds the project's options from now on."""
77 file = self.config_file
78 if file is not None and file.options is None:
79 file.start_options()
80 self._use_config_file(file, self._config_manifest)
82 def _switch_config_file(self, manifest: Path, force: bool = False):
83 """Before another project is opened (or, ``force``, another
84 configuration file of this one): this one's options written, the
85 other's used and applied. (Neither with options: nothing to do but
86 keep New configuration's, which are this project's.)"""
87 if manifest == self._config_manifest and not force:
88 return
89 file = self._config_file_for(manifest)
90 involved = self.options_in_use() or file.options is not None
91 if involved:
92 self._store_window_options()
93 self._write_config_file()
94 elif self.manifest_path is not None: 94 ↛ 96line 94 didn't jump to line 96 because the condition on line 94 was always true
95 self._keep_free_options(self.free_options())
96 self._use_config_file(file, manifest)
97 if involved:
98 self._apply_options()
100 def _write_config_file(self):
101 """Write the project's options to its file, if they changed."""
102 file = self.config_file
103 if file is None or not self.options_in_use():
104 return
105 try:
106 file.write_options(self.settings.complete_options(), self._saved_free_options())
107 except (OSError, bc.ConfigError) as exc:
108 self.statusBar().showMessage(f"Couldn't save the options in {file.path.name}: {exc}", 8000)
110 def reload_config_file(self):
111 """The configuration file was saved in the editor: its configurations
112 read again, and the options changed there (and New configuration, if
113 it was) used and applied; the window's other options, changed since
114 it was read, stay (and the configuration shown)."""
115 file = self.config_file
116 if file is None or self.manifest_path is None: 116 ↛ 117line 116 didn't jump to line 117 because the condition on line 116 was never true
117 return
118 changed, new_changed = file.reread()
119 if not (changed or new_changed):
120 return
121 if not self.options_in_use(): # (it couldn't be read when the project was opened: fixed now)
122 self._use_config_file(file, self._config_manifest)
123 self._apply_options()
124 return
125 options = dict(self.settings.options)
126 for key in changed:
127 if key in file.options: 127 ↛ 130line 127 didn't jump to line 130 because the condition on line 127 was always true
128 options[key] = file.options[key]
129 else:
130 options.pop(key, None)
131 self._use_config_file(file, self._config_manifest, options)
132 self._apply_options(changed | ({NEW_CONFIGURATION_KEY} if new_changed else set()))
134 # ------------------------------------------------------------ New configuration
135 def _saved_free_options(self) -> bc.Configuration:
136 """New configuration's Build options as kept (not as shown: see
137 free_options): the file's, or the app's settings."""
138 file, manifest = self.config_file, self._config_manifest # (the project the settings are for)
139 if self.options_in_use() and file is not None and file.new_configuration is not None:
140 return file.new_configuration
141 s = self.settings.app
142 features = (s.value(project_key(manifest, "features"), [], type=list) or []) if manifest else []
143 return bc.Configuration(
144 "", profile=s.value("profile", bc.DEFAULT_PROFILE) or bc.DEFAULT_PROFILE,
145 features=[str(f) for f in features], compiler=s.value("compiler", "") or "",
146 flags=s.value("flags", "") or "", target=s.value("target", "") or "",
147 extra_args=s.value("extra", "") or "", program_args=s.value("program_args", "") or "",
148 parallel_tests=max(1, s.value("test_jobs", 1, type=int)), coverage=s.value("coverage", False, type=bool),
149 verbose=s.value("verbose", False, type=bool))
151 def _keep_free_options(self, config: bc.Configuration):
152 """Keep New configuration's Build options: in the file (written with
153 the options), or the app's settings."""
154 file = self.config_file
155 if self.options_in_use() and file is not None:
156 file.new_configuration = bc.Configuration.from_json(config.to_json(named=False), name="")
157 return
158 s = self.settings.app
159 for key, value in (("profile", config.profile), ("compiler", config.compiler), ("flags", config.flags),
160 ("extra", config.extra_args), ("target", config.target),
161 ("program_args", config.program_args), ("verbose", config.verbose),
162 ("coverage", config.coverage), ("test_jobs", config.parallel_tests)):
163 s.setValue(key, value)
164 if self._config_manifest is not None:
165 s.setValue(project_key(self._config_manifest, "features"), list(config.features))
167 def _apply_build_options(self):
168 """Build options' fields: New configuration's, as kept (a saved
169 configuration shown stays: they're shown when it's left)."""
170 free = self._saved_free_options()
171 self._wanted_profile = free.profile
172 if self.config_shown():
173 self._free_options = free
174 else:
175 self._show_options(free)
177 # ------------------------------------------------------------ the window
178 def _toggles(self) -> list[tuple]:
179 """(action, settings key, default) of the View menu's on/off choices."""
180 return [(self.use_editor_action, "use_editor", True), (self.lint_action, "lint", True),
181 (self.lsp_diagnostics_action, "lsp_diagnostics", True), (self.spelling_action, "spelling", True),
182 (self.gremlins_action, "gremlins", True), (self.minimap_action, "minimap", True),
183 (self.blame_action, "git_blame", False),
184 (self.coverage_action, "show_coverage", True), (self.matches_action, "highlight_matches", True),
185 (self.word_wrap_action, "word_wrap", False), (self.fortls_action, "fortls", True),
186 (self.trim_on_save_action, "trim_on_save", True)]
188 def _store_window_options(self, front: list[str] | None = None):
189 """Every option as the window has it now, in the settings (the
190 project's options, or the app's), and New configuration's Build
191 options: those changed are there already, not those never changed (a
192 file's options have them all). ``front``: the panels in front of
193 their stacks (as they were before closing began: see closeEvent).
194 (Collapsed panels are opened first: a layout is saved with them open.)"""
195 s = self.settings
196 for action, key, _default in self._toggles():
197 s.setValue(key, action.isChecked())
198 s.setValue("spelling_language", spelling.language)
199 s.setValue("indent_width", CodeEditor.indent_width)
200 s.setValue("indent_tabs", CodeEditor.use_tabs)
201 s.setValue("tab_rows_wrap", self.tab_rows_actions[True].isChecked())
202 for key, actions in self.graph_style_actions.items():
203 chosen = next((value for value, action in actions.items() if action.isChecked()), None)
204 if chosen is not None: 204 ↛ 202line 204 didn't jump to line 202 because the condition on line 204 was always true
205 s.setValue(f"graph_{key}", chosen)
206 s.setValue("graph_dependencies_dev", self.dependencies_dev_action.isChecked())
207 s.setValue("complexity_tests", self.complexity_panel.tests_check.isChecked())
208 s.setValue("complexity_dependencies", self.complexity_panel.dependencies_check.isChecked())
209 s.setValue("coverage_tests", self.coverage_panel.tests_check.isChecked())
210 for source, button in self.problems_panel.buttons.items():
211 s.setValue(f"problems/{source}", button.isChecked())
212 self.search_panel._save_options()
213 front = self.front_panels() if front is None else front
214 self.snap_closed.expand_all()
215 s.setValue("panels", self.saveState(self.LAYOUT_VERSION))
216 s.setValue("panels_front", front)
217 self._keep_free_options(self.free_options())
219 def _apply_options(self, only: set[str] | None = None):
220 """Options, from the settings, to the window: every one (another
221 project's: the window was made with the first's), or ``only`` those
222 (changed in the file, in the editor; NEW_CONFIGURATION_KEY: New
223 configuration's Build options)."""
224 s = self.settings
226 def wanted(*keys: str) -> bool:
227 return only is None or any(key in only for key in keys)
229 if wanted(NEW_CONFIGURATION_KEY):
230 self._apply_build_options()
231 # View
232 for action, key, default in self._toggles():
233 if wanted(key):
234 action.setChecked(s.value(key, default, type=bool)) # (its handler applies it)
235 if wanted("spelling_language"):
236 language = str(s.value("spelling_language", "en") or "en")
237 if language != spelling.language: 237 ↛ 238line 237 didn't jump to line 238 because the condition on line 237 was never true
238 self.set_spelling_language(language)
239 if wanted("indent_width", "indent_tabs"):
240 width = s.value("indent_width", 4, type=int)
241 use_tabs = s.value("indent_tabs", False, type=bool)
242 if (width, use_tabs) != (CodeEditor.indent_width, CodeEditor.use_tabs): 242 ↛ 243line 242 didn't jump to line 243 because the condition on line 242 was never true
243 self.set_indentation(width, use_tabs)
244 if wanted("tab_rows_wrap"):
245 self.set_tab_rows(s.value("tab_rows_wrap", False, type=bool), save=False)
246 # Graph
247 if wanted("graph_layout"):
248 self._set_graph_layout(s.value("graph_layout", next(iter(LAYOUTS))))
249 if wanted("graph_theme"):
250 self._set_graph_theme(s.value("graph_theme", next(iter(THEME_CHOICES))))
251 if wanted("graph_edges"):
252 self._set_graph_edges(s.value("graph_edges", next(iter(EDGE_STYLES))))
253 if wanted("graph_dependencies_dev"):
254 dev = s.value("graph_dependencies_dev", False, type=bool)
255 if only is None: # (another project: it's loaded next anyway) 255 ↛ 257line 255 didn't jump to line 257 because the condition on line 255 was always true
256 self.dependencies_dev_action.blockSignals(True)
257 self.dependencies_dev_action.setChecked(dev)
258 self.dependencies_dev_action.blockSignals(False)
259 # Panels' own choices
260 if wanted("complexity_tests"):
261 self.complexity_panel.tests_check.setChecked(s.value("complexity_tests", False, type=bool))
262 if wanted("complexity_dependencies"):
263 self.complexity_panel.dependencies_check.setChecked(s.value("complexity_dependencies", False, type=bool))
264 if wanted("coverage_tests"):
265 self.coverage_panel.tests_check.setChecked(s.value("coverage_tests", False, type=bool))
266 for source, button in self.problems_panel.buttons.items():
267 if wanted(f"problems/{source}"):
268 button.setChecked(s.value(f"problems/{source}", True, type=bool))
269 if any(wanted(key) for key in po.OPTIONS if key.startswith("search/")):
270 self.search_panel._restore_options()
271 if wanted("panels", "panels_front"):
272 layout = s.value("panels")
273 if layout:
274 self.restore_layout(layout, s.value("panels_front", [], type=list) or [])
275 if only is not None and "build_configuration" in only and not self._editing:
276 # (Changed in the file. Another project's: chosen when its configurations are listed.)
277 self.select_configuration(s.value("build_configuration", "") or "")