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

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. 

6 

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. 

11 

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""" 

16 

17from __future__ import annotations 

18 

19import hashlib 

20from pathlib import Path 

21 

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 

27 

28NEW_CONFIGURATION_KEY = "new_configuration" # (for _apply_options: New configuration's Build options) 

29 

30 

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}" 

37 

38 

39class ProjectOptionsMixin: 

40 """The project's configuration file and its options (a part of 

41 `vibefpmgui.main_window.MainWindow`).""" 

42 

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 

45 

46 @property 

47 def config_path(self) -> Path | None: 

48 return self.config_file.path if self.config_file is not None else None 

49 

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) 

53 

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 

57 

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() 

65 

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) 

74 

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) 

81 

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() 

99 

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) 

109 

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())) 

133 

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)) 

150 

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)) 

166 

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) 

176 

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)] 

187 

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()) 

218 

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 

225 

226 def wanted(*keys: str) -> bool: 

227 return only is None or any(key in only for key in keys) 

228 

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 "")