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
« 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.
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"""
8from __future__ import annotations
10import codecs
11import os
12import sys
13import tempfile
14from collections import deque
15from pathlib import Path
17from qtpy.QtCore import QProcess, QSettings, Qt
18from qtpy.QtGui import QAction
19from qtpy.QtWidgets import QApplication, QMainWindow, QMessageBox
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
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."""
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] = []
106 self._build_ui()
107 self._restore_settings()
108 self._update_editor_actions()
110 if start is not None:
111 self.open_manifest(start)
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()
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))))
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()
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)
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")
242_APP_NAME = "VibeFPMGUI"
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:]]
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()