Coverage for vibefpmgui/main_window.py: 97%

199 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-11-01 20:51 +0000

1"""Main window: pick an fpm.toml, run fpm commands, view the dependency graph. 

2 

3`MainWindow` is put together from the mixins in `vibefpmgui.window` (one 

4per area of the window); here it's started, built in order and closed, 

5and `main` starts the app. 

6""" 

7 

8from __future__ import annotations 

9 

10import codecs 

11import os 

12import sys 

13import tempfile 

14from collections import deque 

15from pathlib import Path 

16 

17from qtpy.QtCore import QProcess, QSettings, Qt 

18from qtpy.QtGui import QAction 

19from qtpy.QtWidgets import QApplication, QMainWindow, QMessageBox 

20 

21from . import __version__, lsp, themes 

22from . import manifest as m 

23from .compat import set_macos_bundle_name, set_macos_display_name, set_windows_app_id 

24from .graph_view import EDGE_STYLES, LAYOUTS, THEME_CHOICES 

25from .icons import app_icon 

26from .parallel_tests import ParallelTests 

27from .project_options import LayeredSettings 

28from .window.ai import AIMixin 

29from .window.ai_chat import AIChatMixin 

30from .window.build_outputs import BuildOutputsMixin 

31from .window.complexity import ComplexityMixin 

32from .window.configs import ConfigsMixin 

33from .window.coverage import CoverageMixin 

34from .window.debugging import DebuggingMixin 

35from .window.editing import EditingMixin 

36from .window.editor_tabs import EditorTabsMixin 

37from .window.git import GitMixin 

38from .window.language import LanguageMixin 

39from .window.options import ProjectOptionsMixin 

40from .window.output import OUTPUT_TAIL_LINES, OutputMixin 

41from .window.packages import PackagesMixin 

42from .window.panels import PanelsMixin 

43from .window.project import ProjectMixin 

44from .window.running import RunningMixin 

45from .window.search import SearchMixin 

46from .window.testing import TestingMixin 

47from .window.view import ViewMixin 

48 

49 

50class MainWindow(PanelsMixin, ViewMixin, ProjectMixin, PackagesMixin, 

51 EditorTabsMixin, EditingMixin, LanguageMixin, SearchMixin, GitMixin, 

52 RunningMixin, DebuggingMixin, ConfigsMixin, ProjectOptionsMixin, BuildOutputsMixin, OutputMixin, TestingMixin, CoverageMixin, ComplexityMixin, 

53 AIMixin, AIChatMixin, 

54 QMainWindow): 

55 """The window. Each part of it is a mixin in `vibefpmgui.window`; this 

56 class starts it, builds it (in order: `_build_ui`), and closes it.""" 

57 

58 def __init__(self, manifest_path: str | None = None, remember_last: bool = True): 

59 """``remember_last`` False: an extra window (File → Open in New Window) 

60 doesn't become the project reopened at the next start.""" 

61 super().__init__() 

62 self.remember_last = remember_last 

63 self.setWindowTitle("VibeFPMGUI") 

64 self.resize(1300, 850) 

65 self.settings = LayeredSettings(make_settings()) 

66 # The project opened first: its options (if its configuration file has 

67 # them) are the settings the window is made with. 

68 start = manifest_path or self.settings.value("last_manifest", "") 

69 start = Path(start).resolve() if start and Path(start).is_file() else None 

70 self._use_config_file(self._config_file_for(start) if start is not None else None, start) 

71 themes.chosen_style = str(self.settings.value("app_style", themes.AUTOMATIC) or themes.AUTOMATIC) 

72 themes.apply(str(self.settings.value("app_theme", themes.SYSTEM) or themes.SYSTEM)) # (before the widgets) 

73 self._session_busy = False # restoring the open files (don't save meanwhile) 

74 self.lsp_client = lsp.LspClient(self) # fortls, for the editor (see _update_language_server) 

75 self.lsp_client.stopped.connect(self._language_server_stopped) 

76 self.lsp_client.message.connect(lambda text: self.statusBar().showMessage(f"fortls: {text}", 8000)) 

77 self.lsp_client.diagnostics.connect(self._lsp_diagnostics) 

78 self._lsp_diagnostics_by_file: dict[Path, list] = {} # the latest, to show again when turned on 

79 self.manifest_path: Path | None = None 

80 self.graph: m.DependencyGraph | None = None 

81 self.process: QProcess | None = None 

82 self.parallel_tests: ParallelTests | None = None # tests run by us, several at once 

83 self._refresh_after = False 

84 self._stop_requested = False # Stop was pressed for the running process (see stop) 

85 self._monitoring = False 

86 self._partial = "" # output text after the last newline 

87 # The running command's output as text: a character whose bytes arrive 

88 # in two reads is decoded whole (see _begin_output). 

89 self._decoder = codecs.getincrementaldecoder("utf-8")(errors="replace") 

90 self._esc_tail = "" # color code cut off at the end of the last output chunk 

91 self._full_output_path = Path(tempfile.gettempdir()) / f"vibefpmgui-output-{os.getpid()}.log" 

92 self._full_output = None # the file the running command's whole output is saved to 

93 self._shown_chars = 0 # how much of it the Output panel shows (see OUTPUT_SHOW_LIMIT) 

94 self._hidden_chars = 0 

95 self._hidden_lines = 0 

96 self._tail: deque[str] = deque(maxlen=OUTPUT_TAIL_LINES) 

97 self._tail_partial = "" 

98 # None, or while running Test/Run: "list" (fpm ... --list), "marked" 

99 # (fpm ... --runner <wrapper>) or "direct" (a built program, via the wrapper) 

100 self._phase: str | None = None 

101 self._captured: list[str] = [] # output of "fpm test --list" 

102 self._held_blank = False # blank line that may precede a test marker 

103 self._test_args: tuple = () 

104 self.command_actions: list[QAction] = [] 

105 

106 self._build_ui() 

107 self._restore_settings() 

108 self._update_editor_actions() 

109 

110 if start is not None: 

111 self.open_manifest(start) 

112 

113 # ------------------------------------------------------------------ UI 

114 def _build_ui(self): 

115 """The menus, the panels and the editor, in this order (later steps 

116 connect to what earlier ones made).""" 

117 self._build_file_menu() 

118 self._build_edit_menu() 

119 self._build_view_menu() 

120 options = self._build_options_panel() 

121 self._build_commands() 

122 self._build_debugging() 

123 self._build_features_menu() 

124 self._build_ai_menu() 

125 files_box = self._build_project_files() 

126 graph_tab = self._build_graph_panel() 

127 self._build_editor_tabs() 

128 self._build_outputs_view() 

129 console_box = self._build_output_panel() 

130 self._build_run_panels() 

131 self._build_review_panel() 

132 self._build_search_panels() 

133 self._build_terminal() 

134 self._build_git_panel() 

135 self._build_complexity_panel() 

136 self._link_file_lists() 

137 self._build_central() 

138 self._build_docks(options, files_box, graph_tab, console_box) 

139 self.statusBar().showMessage("Ready") 

140 self._set_project_enabled(False) 

141 self._build_help_menu() 

142 

143 # ------------------------------------------------------------ settings 

144 def _restore_settings(self): 

145 s = self.settings 

146 geometry = s.value("geometry") 

147 if geometry is not None: 

148 self.restoreGeometry(geometry) 

149 layout = s.value("panels") # (where each panel was: docked, stacked, floating, hidden) 

150 self._layout_restored = layout is not None and self.restore_layout( 

151 layout, s.value("panels_front", [], type=list) or []) 

152 # A layout saved before a panel existed: the panel goes in the stack of 

153 # the one it's next to by default. 

154 for name, beside in (("coverage", "tests"), ("git", "search"), ("complexity", "coverage"), 

155 ("debug", "run")): 

156 dock = self.docks[name] 

157 if self._layout_restored and self.dockWidgetArea(dock) == Qt.NoDockWidgetArea: 157 ↛ 158line 157 didn't jump to line 158 because the condition on line 157 was never true

158 self.addDockWidget(Qt.BottomDockWidgetArea, dock) 

159 self.tabifyDockWidget(self.docks[beside], dock) 

160 # Applied once the project's own [profiles] are in the list. 

161 self._apply_build_options() 

162 self.fpm_edit.setText(s.value("fpm", "")) 

163 self.set_font_delta(s.value("font_delta", 0, type=int), announce=False) 

164 self._set_graph_layout(s.value("graph_layout", next(iter(LAYOUTS)))) 

165 self._set_graph_theme(s.value("graph_theme", next(iter(THEME_CHOICES)))) 

166 self._set_graph_edges(s.value("graph_edges", next(iter(EDGE_STYLES)))) 

167 

168 def clear_settings_and_quit(self): 

169 """Forget every saved setting and quit, so the next start is like the 

170 first (after asking; nothing happens if a window can't close).""" 

171 answer = QMessageBox.warning( 

172 self, "Clear Settings and Quit", 

173 "Clear all of VibeFPMGUI's saved settings and quit?\n\n" 

174 "Recent projects, open files, window and panel layout, build options, View menu " 

175 "choices and your spelling dictionary are all forgotten; the next start is like " 

176 "the first. Your files are not touched.\n\n" 

177 "Close any other VibeFPMGUI windows first: they save their settings when they close.", 

178 QMessageBox.Yes | QMessageBox.Cancel, QMessageBox.Cancel) 

179 if answer != QMessageBox.Yes: 

180 return 

181 self._clearing_settings = True # (closeEvent: nothing saved) 

182 if not self.close(): # (unsaved files: Cancel keeps the window and the settings) 182 ↛ 183line 182 didn't jump to line 183 because the condition on line 182 was never true

183 self._clearing_settings = False 

184 return 

185 self.settings.sync() # (anything written while closing, then all of it removed) 

186 self.settings.clear() 

187 self.settings.sync() 

188 QApplication.quit() 

189 

190 def closeEvent(self, event): 

191 clearing = getattr(self, "_clearing_settings", False) 

192 # (Before closing brings other panels to the front: e.g. stopping a review shows Build.) 

193 front = self.front_panels() 

194 if not clearing: 

195 self._save_session() 

196 # Every question first: answering Cancel or No leaves everything running. 

197 if not self.maybe_save_configuration(): 

198 event.ignore() 

199 return 

200 for editor in self.editors(): 

201 self.tabs.setCurrentWidget(editor) 

202 if not editor.maybe_save(): 

203 event.ignore() 

204 return 

205 if self.busy(): 

206 answer = QMessageBox.question(self, "fpm is running", 

207 "An fpm command is still running. Stop it and quit?") 

208 if answer != QMessageBox.Yes: 

209 event.ignore() 

210 return 

211 self._stop_and_wait(2000) 

212 for editor in self.editors(): 

213 editor.close_down() 

214 self.debug_session.stop() 

215 self.lsp_client.stop() 

216 self.search_panel.shutdown() 

217 self.terminal_panel.shutdown() 

218 self._shutdown_ai() 

219 self._stop_coverage() 

220 self._end_output(show_tail=False) 

221 self._full_output_path.unlink(missing_ok=True) # (the full-output log: only for this run) 

222 if clearing: 

223 super().closeEvent(event) 

224 return 

225 s = self.settings 

226 s.setValue("geometry", self.saveGeometry()) 

227 s.setValue("fpm", self.fpm_edit.text()) 

228 self._store_window_options(front) # (the project's options, or the app's settings) 

229 self._write_config_file() 

230 super().closeEvent(event) 

231 

232 

233def make_settings() -> QSettings: 

234 """The app's saved settings. VIBEFPMGUI_SETTINGS=/path/file.ini uses a 

235 separate file instead (e.g. for testing without touching your own).""" 

236 path = os.environ.get("VIBEFPMGUI_SETTINGS") 

237 if path: 237 ↛ 239line 237 didn't jump to line 239 because the condition on line 237 was always true

238 return QSettings(path, QSettings.IniFormat) 

239 return QSettings("VibeFPMGUI", "VibeFPMGUI") 

240 

241 

242_APP_NAME = "VibeFPMGUI" 

243 

244 

245def qt_argv(argv: list[str]) -> list[str]: 

246 """The command line for QApplication: the app's name in place of the 

247 script's ("…/vibefpmgui/__main__.py" when started with python -m).""" 

248 return [_APP_NAME, *argv[1:]] 

249 

250 

251def main(argv: list[str] | None = None) -> int: 

252 argv = sys.argv if argv is None else argv 

253 set_windows_app_id() # Windows: our own taskbar icon, not python.exe's 

254 set_macos_bundle_name(_APP_NAME) # macOS: our name in the menu bar, not "Python" (before Qt starts) 

255 # (argv[0] is the name Qt uses for macOS's About/Hide/Quit items: "__main__.py" otherwise.) 

256 app = QApplication.instance() or QApplication(qt_argv(argv)) 

257 set_macos_display_name(_APP_NAME) # and in the Dock, not "python3.14" 

258 app.setApplicationName(_APP_NAME) 

259 app.setWindowIcon(app_icon()) # also the Dock icon on macOS 

260 app.setApplicationVersion(__version__) 

261 path = next((a for a in argv[1:] if not a.startswith("-")), None) 

262 if path and Path(path).is_dir(): 

263 path = str(Path(path) / "fpm.toml") 

264 # --new-window: opened from another window, so not the project to reopen next time 

265 window = MainWindow(path, remember_last="--new-window" not in argv[1:]) 

266 window.show() 

267 return app.exec()