vibefpmgui.project_options
A project's GUI options, in its build configuration file's "options"
(see vibefpmgui.build_configs): the View menu's choices, the panels'
layout, the editor's tabs and the files open in them… everything the app
keeps in its settings that's about how the project is worked on (not how
the app looks: that's the person's, wherever they work). (New
configuration's Build options are in the file too, but as a configuration:
see vibefpmgui.window.options.)
Not in it (kept in the app's settings, for this computer): the look (theme, window style, syntax colors, editor font, text size), the window's size and place, the recent lists and the last project, the command palette's recent commands, the spelling dictionary, the AI choices, the fpm executable, which configuration file a project uses, and what the app has learned (tests' durations, which build folders a build wrote).
LayeredSettings stands in for the app's QSettings: while a project's
options are in use (options not None), an option is read from them (the
app's value if they don't have it yet) and written to them; anything else,
and every option while there are none, is the app's. An option the app
keeps per project (PER_PROJECT) is asked for by its name; the app's key
for it is the project's (see use). In the file, the open
files and the Project files tree's folders inside the project are relative
to it (so it works wherever the project is).
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`.) 8 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). 15 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""" 25 26from __future__ import annotations 27 28import json 29from pathlib import Path 30 31from qtpy.QtCore import QByteArray 32 33BYTES, JSON = "bytes", "json" # (kinds besides bool, int, float, str and list) 34 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") 56 57 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) 63 64 65def _truthy(value) -> bool: 66 return value in (True, 1, "true", "1") if not isinstance(value, bool) else value 67 68 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: 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) 84 85 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: 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 102 103 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 109 110 111def _absolute(path: str, root: Path) -> str: 112 return path if Path(path).is_absolute() else str(root / path) 113 114 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): 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) 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 134 135 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).""" 139 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 145 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 {}) 153 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 157 158 def _in_options(self, key: str) -> bool: 159 return self.options is not None and kind_of(key) is not None 160 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) 173 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) 183 184 def contains(self, key: str) -> bool: 185 if self._in_options(key) and key in self.options: 186 return True 187 source = self._app_key(key) 188 return source is not None and self.app.contains(source) 189 190 def remove(self, key: str): 191 if self._in_options(key): 192 self.options.pop(key, None) 193 elif (source := self._app_key(key)) is not None: 194 self.app.remove(source) 195 196 @staticmethod 197 def _default(kind): 198 return {bool: False, int: 0, float: 0.0, list: [], str: ""}.get(kind) 199 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 223 224 def __getattr__(self, name): 225 return getattr(self.app, name)
59def kind_of(key: str): 60 """The option's kind (None: not an option, always the app's).""" 61 if key in OPTIONS: 62 return OPTIONS[key] 63 return next((kind for prefix, kind in PREFIXES.items() if key.startswith(prefix)), None)
The option's kind (None: not an option, always the app's).
70def to_file(value, kind): 71 """A value as the file has it (JSON's types).""" 72 if kind == BYTES: 73 return bytes(QByteArray(value).toBase64()).decode("ascii") if value else "" 74 if kind == JSON: 75 return json.loads(value) if isinstance(value, str) and value else (value or {}) 76 if kind is bool: 77 return _truthy(value) 78 if kind is int: 79 return int(value) 80 if kind is float: 81 return float(value) 82 if kind is list: 83 return [str(v) for v in value] if isinstance(value, (list, tuple)) else ([str(value)] if value else []) 84 return "" if value is None else str(value)
A value as the file has it (JSON's types).
87def from_file(value, kind, default): 88 """A value from the file, as the app's settings would give it (``default`` 89 if it's of the wrong type).""" 90 if kind == BYTES: 91 return QByteArray.fromBase64(value.encode("ascii")) if isinstance(value, str) and value else default 92 if kind == JSON: 93 return json.dumps(value) if isinstance(value, dict) else default 94 if kind is bool: 95 return value if isinstance(value, bool) else default 96 if kind is int: 97 return value if isinstance(value, int) and not isinstance(value, bool) else default 98 if kind is float: 99 return float(value) if isinstance(value, (int, float)) and not isinstance(value, bool) else default 100 if kind is list: 101 return list(value) if isinstance(value, list) else default 102 return value if isinstance(value, str) else default
A value from the file, as the app's settings would give it (default
if it's of the wrong type).
137class LayeredSettings: 138 """The app's settings (``app``), with a project's options over them while 139 ``options`` isn't None (see the module's description).""" 140 141 def __init__(self, app): 142 self.app = app 143 self.options: dict | None = None 144 self.root: Path | None = None # the project's folder (the session's paths are relative to it) 145 self.project_keys: dict[str, str] = {} # the app's key of each PER_PROJECT option, for this project 146 147 def use(self, options: dict | None, root: Path | None = None, project_keys: dict[str, str] | None = None): 148 """Use a project's options from now on (None: the app's settings 149 only); ``project_keys``: the app's key of each PER_PROJECT option for 150 the project (none: there's no project).""" 151 self.options = None if options is None else dict(options) 152 self.root = root 153 self.project_keys = dict(project_keys or {}) 154 155 def _app_key(self, key: str) -> str | None: 156 """The app's key for ``key`` (None: a per-project one, with no project).""" 157 return self.project_keys.get(key) if key in PER_PROJECT else key 158 159 def _in_options(self, key: str) -> bool: 160 return self.options is not None and kind_of(key) is not None 161 162 def value(self, key: str, default=None, type=None): 163 kind = kind_of(key) 164 if self._in_options(key) and key in self.options: 165 value = self.options[key] 166 if kind == JSON and isinstance(value, dict) and self.root is not None: 167 value = _session_paths(value, self.root, _absolute) 168 fallback = default if default is not None else self._default(kind) 169 return from_file(value, kind, fallback) 170 source = self._app_key(key) 171 if source is None: 172 return default 173 return self.app.value(source, default) if type is None else self.app.value(source, default, type=type) 174 175 def setValue(self, key: str, value): 176 if self._in_options(key): 177 kind = kind_of(key) 178 value = to_file(value, kind) 179 if kind == JSON and isinstance(value, dict) and self.root is not None: 180 value = _session_paths(value, self.root, _relative) 181 self.options[key] = value 182 elif (source := self._app_key(key)) is not None: 183 self.app.setValue(source, value) 184 185 def contains(self, key: str) -> bool: 186 if self._in_options(key) and key in self.options: 187 return True 188 source = self._app_key(key) 189 return source is not None and self.app.contains(source) 190 191 def remove(self, key: str): 192 if self._in_options(key): 193 self.options.pop(key, None) 194 elif (source := self._app_key(key)) is not None: 195 self.app.remove(source) 196 197 @staticmethod 198 def _default(kind): 199 return {bool: False, int: 0, float: 0.0, list: [], str: ""}.get(kind) 200 201 def complete_options(self) -> dict: 202 """The options to write: those set, and the app's values for the 203 others it has (so the file has them all).""" 204 out = {} 205 prefixed = {k for k in [*self.app.allKeys(), *(self.options or {})] if any(k.startswith(p) for p in PREFIXES)} 206 names = set(OPTIONS) | prefixed 207 for name in sorted(names): 208 if name in (self.options or {}): 209 out[name] = self.options[name] 210 continue 211 source = self._app_key(name) 212 if source is None or not self.app.contains(source): 213 continue 214 kind = kind_of(name) 215 raw = self.app.value(source) 216 try: 217 value = to_file(raw if kind is not bool else _truthy(raw), kind) 218 except (TypeError, ValueError): 219 continue 220 if kind == JSON and isinstance(value, dict) and self.root is not None: 221 value = _session_paths(value, self.root, _relative) 222 out[name] = value 223 return out 224 225 def __getattr__(self, name): 226 return getattr(self.app, name)
The app's settings (app), with a project's options over them while
options isn't None (see the module's description).
147 def use(self, options: dict | None, root: Path | None = None, project_keys: dict[str, str] | None = None): 148 """Use a project's options from now on (None: the app's settings 149 only); ``project_keys``: the app's key of each PER_PROJECT option for 150 the project (none: there's no project).""" 151 self.options = None if options is None else dict(options) 152 self.root = root 153 self.project_keys = dict(project_keys or {})
Use a project's options from now on (None: the app's settings
only); project_keys: the app's key of each PER_PROJECT option for
the project (none: there's no project).
162 def value(self, key: str, default=None, type=None): 163 kind = kind_of(key) 164 if self._in_options(key) and key in self.options: 165 value = self.options[key] 166 if kind == JSON and isinstance(value, dict) and self.root is not None: 167 value = _session_paths(value, self.root, _absolute) 168 fallback = default if default is not None else self._default(kind) 169 return from_file(value, kind, fallback) 170 source = self._app_key(key) 171 if source is None: 172 return default 173 return self.app.value(source, default) if type is None else self.app.value(source, default, type=type)
175 def setValue(self, key: str, value): 176 if self._in_options(key): 177 kind = kind_of(key) 178 value = to_file(value, kind) 179 if kind == JSON and isinstance(value, dict) and self.root is not None: 180 value = _session_paths(value, self.root, _relative) 181 self.options[key] = value 182 elif (source := self._app_key(key)) is not None: 183 self.app.setValue(source, value)
201 def complete_options(self) -> dict: 202 """The options to write: those set, and the app's values for the 203 others it has (so the file has them all).""" 204 out = {} 205 prefixed = {k for k in [*self.app.allKeys(), *(self.options or {})] if any(k.startswith(p) for p in PREFIXES)} 206 names = set(OPTIONS) | prefixed 207 for name in sorted(names): 208 if name in (self.options or {}): 209 out[name] = self.options[name] 210 continue 211 source = self._app_key(name) 212 if source is None or not self.app.contains(source): 213 continue 214 kind = kind_of(name) 215 raw = self.app.value(source) 216 try: 217 value = to_file(raw if kind is not bool else _truthy(raw), kind) 218 except (TypeError, ValueError): 219 continue 220 if kind == JSON and isinstance(value, dict) and self.root is not None: 221 value = _session_paths(value, self.root, _relative) 222 out[name] = value 223 return out
The options to write: those set, and the app's values for the others it has (so the file has them all).