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

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

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

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

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

189 

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) 

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)