Coverage for vibefpmgui/project_options.py: 92%
136 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"""A project's GUI options, in its build configuration file's "options"
2(see `vibefpmgui.build_configs`): the View menu's choices, the panels'
3layout, the editor's tabs and the files open in them… everything the app
4keeps in its settings that's about how the project is worked on (not how
5the app looks: that's the person's, wherever they work). (New
6configuration's Build options are in the file too, but as a configuration:
7see `vibefpmgui.window.options`.)
9Not in it (kept in the app's settings, for this computer): the look (theme,
10window style, syntax colors, editor font, text size), the window's size
11and place, the recent lists and the last project, the command palette's
12recent commands, the spelling dictionary, the AI choices, the fpm executable,
13which configuration file a project uses, and what the app has learned (tests'
14durations, which build folders a build wrote).
16`LayeredSettings` stands in for the app's QSettings: while a project's
17options are in use (``options`` not None), an option is read from them (the
18app's value if they don't have it yet) and written to them; anything else,
19and every option while there are none, is the app's. An option the app
20keeps per project (`PER_PROJECT`) is asked for by its name; the app's key
21for it is the project's (see ``use``). In the file, the open
22files and the Project files tree's folders inside the project are relative
23to it (so it works wherever the project is).
24"""
26from __future__ import annotations
28import json
29from pathlib import Path
31from qtpy.QtCore import QByteArray
33BYTES, JSON = "bytes", "json" # (kinds besides bool, int, float, str and list)
35# Each option, by its settings key, and the kind of value it has.
36OPTIONS: dict[str, object] = {
37 # View
38 "use_editor": bool, "lint": bool,
39 "lsp_diagnostics": bool, "spelling": bool, "spelling_language": str, "gremlins": bool, "minimap": bool, "git_blame": bool,
40 "show_coverage": bool, "highlight_matches": bool, "word_wrap": bool, "indent_width": int,
41 "indent_tabs": bool, "fortls": bool, "trim_on_save": bool,
42 # The editor's tabs, the panels
43 "tab_rows_wrap": bool, "panels": BYTES, "panels_front": list, "session": JSON,
44 # Graph
45 "graph_layout": str, "graph_theme": str, "graph_edges": str, "graph_dependencies_dev": bool,
46 # Panels' own choices
47 "complexity_tests": bool, "complexity_dependencies": bool, "coverage_tests": bool,
48 "search/case": bool, "search/word": bool, "search/regex": bool, "search/dependencies": bool,
49 "search/ignored": bool, "search/include": str, "search/exclude": str,
50 # The configuration chosen
51 "build_configuration": str,
52}
53PREFIXES: dict[str, object] = {"problems/": bool} # (one key per source of problems)
54# Options the app keeps per project (its settings key: "<name>/<hash of the project>").
55PER_PROJECT = ("session", "build_configuration")
58def kind_of(key: str):
59 """The option's kind (None: not an option, always the app's)."""
60 if key in OPTIONS:
61 return OPTIONS[key]
62 return next((kind for prefix, kind in PREFIXES.items() if key.startswith(prefix)), None)
65def _truthy(value) -> bool:
66 return value in (True, 1, "true", "1") if not isinstance(value, bool) else value
69def to_file(value, kind):
70 """A value as the file has it (JSON's types)."""
71 if kind == BYTES:
72 return bytes(QByteArray(value).toBase64()).decode("ascii") if value else ""
73 if kind == JSON:
74 return json.loads(value) if isinstance(value, str) and value else (value or {})
75 if kind is bool:
76 return _truthy(value)
77 if kind is int:
78 return int(value)
79 if kind is float: 79 ↛ 80line 79 didn't jump to line 80 because the condition on line 79 was never true
80 return float(value)
81 if kind is list:
82 return [str(v) for v in value] if isinstance(value, (list, tuple)) else ([str(value)] if value else [])
83 return "" if value is None else str(value)
86def from_file(value, kind, default):
87 """A value from the file, as the app's settings would give it (``default``
88 if it's of the wrong type)."""
89 if kind == BYTES:
90 return QByteArray.fromBase64(value.encode("ascii")) if isinstance(value, str) and value else default
91 if kind == JSON:
92 return json.dumps(value) if isinstance(value, dict) else default
93 if kind is bool:
94 return value if isinstance(value, bool) else default
95 if kind is int:
96 return value if isinstance(value, int) and not isinstance(value, bool) else default
97 if kind is float: 97 ↛ 98line 97 didn't jump to line 98 because the condition on line 97 was never true
98 return float(value) if isinstance(value, (int, float)) and not isinstance(value, bool) else default
99 if kind is list:
100 return list(value) if isinstance(value, list) else default
101 return value if isinstance(value, str) else default
104def _relative(path: str, root: Path) -> str:
105 try:
106 return Path(path).relative_to(root).as_posix() or "."
107 except ValueError:
108 return path
111def _absolute(path: str, root: Path) -> str:
112 return path if Path(path).is_absolute() else str(root / path)
115def _session_paths(state: dict, root: Path, change) -> dict:
116 """The session's paths (open files, the one in front, the tree's open
117 folders and selection) changed with ``change`` (to relative or absolute)."""
118 state = json.loads(json.dumps(state)) # (a copy)
119 for f in state.get("files") or []:
120 if isinstance(f, dict) and isinstance(f.get("path"), str): 120 ↛ 119line 120 didn't jump to line 119 because the condition on line 120 was always true
121 f["path"] = change(f["path"], root)
122 if isinstance(state.get("current"), str):
123 state["current"] = change(state["current"], root)
124 for entry in state.get("breakpoints") or []: # (see window.debugging) 124 ↛ 125line 124 didn't jump to line 125 because the loop on line 124 never started
125 if isinstance(entry, dict) and isinstance(entry.get("path"), str):
126 entry["path"] = change(entry["path"], root)
127 tree = state.get("tree")
128 if isinstance(tree, dict):
129 tree["expanded"] = [p if not isinstance(p, str) or p.startswith("group:") else change(p, root)
130 for p in tree.get("expanded") or []]
131 if isinstance(tree.get("selected"), str):
132 tree["selected"] = change(tree["selected"], root)
133 return state
136class LayeredSettings:
137 """The app's settings (``app``), with a project's options over them while
138 ``options`` isn't None (see the module's description)."""
140 def __init__(self, app):
141 self.app = app
142 self.options: dict | None = None
143 self.root: Path | None = None # the project's folder (the session's paths are relative to it)
144 self.project_keys: dict[str, str] = {} # the app's key of each PER_PROJECT option, for this project
146 def use(self, options: dict | None, root: Path | None = None, project_keys: dict[str, str] | None = None):
147 """Use a project's options from now on (None: the app's settings
148 only); ``project_keys``: the app's key of each PER_PROJECT option for
149 the project (none: there's no project)."""
150 self.options = None if options is None else dict(options)
151 self.root = root
152 self.project_keys = dict(project_keys or {})
154 def _app_key(self, key: str) -> str | None:
155 """The app's key for ``key`` (None: a per-project one, with no project)."""
156 return self.project_keys.get(key) if key in PER_PROJECT else key
158 def _in_options(self, key: str) -> bool:
159 return self.options is not None and kind_of(key) is not None
161 def value(self, key: str, default=None, type=None):
162 kind = kind_of(key)
163 if self._in_options(key) and key in self.options:
164 value = self.options[key]
165 if kind == JSON and isinstance(value, dict) and self.root is not None:
166 value = _session_paths(value, self.root, _absolute)
167 fallback = default if default is not None else self._default(kind)
168 return from_file(value, kind, fallback)
169 source = self._app_key(key)
170 if source is None:
171 return default
172 return self.app.value(source, default) if type is None else self.app.value(source, default, type=type)
174 def setValue(self, key: str, value):
175 if self._in_options(key):
176 kind = kind_of(key)
177 value = to_file(value, kind)
178 if kind == JSON and isinstance(value, dict) and self.root is not None:
179 value = _session_paths(value, self.root, _relative)
180 self.options[key] = value
181 elif (source := self._app_key(key)) is not None:
182 self.app.setValue(source, value)
184 def contains(self, key: str) -> bool:
185 if self._in_options(key) and key in self.options: 185 ↛ 186line 185 didn't jump to line 186 because the condition on line 185 was never true
186 return True
187 source = self._app_key(key)
188 return source is not None and self.app.contains(source)
190 def remove(self, key: str):
191 if self._in_options(key): 191 ↛ 192line 191 didn't jump to line 192 because the condition on line 191 was never true
192 self.options.pop(key, None)
193 elif (source := self._app_key(key)) is not None: 193 ↛ exitline 193 didn't return from function 'remove' because the condition on line 193 was always true
194 self.app.remove(source)
196 @staticmethod
197 def _default(kind):
198 return {bool: False, int: 0, float: 0.0, list: [], str: ""}.get(kind)
200 def complete_options(self) -> dict:
201 """The options to write: those set, and the app's values for the
202 others it has (so the file has them all)."""
203 out = {}
204 prefixed = {k for k in [*self.app.allKeys(), *(self.options or {})] if any(k.startswith(p) for p in PREFIXES)}
205 names = set(OPTIONS) | prefixed
206 for name in sorted(names):
207 if name in (self.options or {}):
208 out[name] = self.options[name]
209 continue
210 source = self._app_key(name)
211 if source is None or not self.app.contains(source):
212 continue
213 kind = kind_of(name)
214 raw = self.app.value(source)
215 try:
216 value = to_file(raw if kind is not bool else _truthy(raw), kind)
217 except (TypeError, ValueError):
218 continue
219 if kind == JSON and isinstance(value, dict) and self.root is not None:
220 value = _session_paths(value, self.root, _relative)
221 out[name] = value
222 return out
224 def __getattr__(self, name):
225 return getattr(self.app, name)