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)
OPTIONS: dict[str, object] = {'use_editor': <class 'bool'>, 'lint': <class 'bool'>, 'lsp_diagnostics': <class 'bool'>, 'spelling': <class 'bool'>, 'spelling_language': <class 'str'>, 'gremlins': <class 'bool'>, 'minimap': <class 'bool'>, 'git_blame': <class 'bool'>, 'show_coverage': <class 'bool'>, 'highlight_matches': <class 'bool'>, 'word_wrap': <class 'bool'>, 'indent_width': <class 'int'>, 'indent_tabs': <class 'bool'>, 'fortls': <class 'bool'>, 'trim_on_save': <class 'bool'>, 'tab_rows_wrap': <class 'bool'>, 'panels': 'bytes', 'panels_front': <class 'list'>, 'session': 'json', 'graph_layout': <class 'str'>, 'graph_theme': <class 'str'>, 'graph_edges': <class 'str'>, 'graph_dependencies_dev': <class 'bool'>, 'complexity_tests': <class 'bool'>, 'complexity_dependencies': <class 'bool'>, 'coverage_tests': <class 'bool'>, 'search/case': <class 'bool'>, 'search/word': <class 'bool'>, 'search/regex': <class 'bool'>, 'search/dependencies': <class 'bool'>, 'search/ignored': <class 'bool'>, 'search/include': <class 'str'>, 'search/exclude': <class 'str'>, 'build_configuration': <class 'str'>}
PREFIXES: dict[str, object] = {'problems/': <class 'bool'>}
PER_PROJECT = ('session', 'build_configuration')
def kind_of(key: str):
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).

def to_file(value, kind):
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).

def from_file(value, kind, default):
 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).

class LayeredSettings:
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).

LayeredSettings(app)
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
app
options: dict | None
root: pathlib.Path | None
project_keys: dict[str, str]
def use( self, options: dict | None, root: pathlib.Path | None = None, project_keys: dict[str, str] | None = None):
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).

def value(self, key: str, default=None, type=None):
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)
def setValue(self, key: str, value):
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)
def contains(self, key: str) -> bool:
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)
def remove(self, key: str):
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)
def complete_options(self) -> dict:
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).