vibefpmgui.window.view
The View menu: text size, fonts, theme, colors and the editor's options.
Each option is saved in the settings and applied to every open editor.
1"""The View menu: text size, fonts, theme, colors and the editor's options. 2 3Each option is saved in the settings and applied to every open editor. 4""" 5 6from __future__ import annotations 7 8import sys 9 10from qtpy.QtCore import QEvent, QSize, Qt, QUrl 11from qtpy.QtGui import ( 12 QAction, 13 QActionGroup, 14 QDesktopServices, 15 QFont, 16 QFontDatabase, 17 QKeySequence, 18) 19from qtpy.QtWidgets import QApplication, QFontDialog, QMessageBox, QPlainTextEdit 20 21from .. import editor as editor_mod 22from .. import highlighter as hl 23from .. import lint, lsp, spelling, themes 24from ..about import FPM_DOCS_URL, AboutDialog 25from ..build_monitor import set_ui_scale 26from ..code_editor import CodeEditor 27from ..command_palette import Command, CommandPalette 28from ..editor import EditorTab, monospace_font 29from ..editor_shared import INDENT_WIDTHS 30from ..widgets import run_dialog 31 32 33class ViewMixin: 34 """The View menu and how the window looks (a part of `vibefpmgui.main_window.MainWindow`).""" 35 36 def _build_view_menu(self): 37 """View menu: text size, editor options, theme and colors (Panels: _build_docks).""" 38 # View menu: text size for the whole window. 39 # The system font, recorded once: later windows would otherwise see the 40 # already-enlarged application font as "normal". 41 app = QApplication.instance() 42 if app.property("vibefpm_base_font") is None: 43 app.setProperty("vibefpm_base_font", QFont(app.font())) 44 self._base_font = QFont(app.property("vibefpm_base_font")) 45 self.font_delta = 0 46 view_menu = self.view_menu = self.menuBar().addMenu("&View") 47 # Every command in the menus, found by typing (see vibefpmgui.command_palette). 48 self.palette_action = view_menu.addAction("Command Palette…") 49 self.palette_action.setShortcut(QKeySequence("Ctrl+Shift+P")) # (⌘⇧P on macOS) 50 self.palette_action.setStatusTip("Find any command in the menus by typing part of its name") 51 self.palette_action.triggered.connect(self.show_command_palette) 52 self._command_palette: CommandPalette | None = None 53 view_menu.addSeparator() 54 self.bigger_action = QAction("Bigger Text", self) 55 self.bigger_action.setShortcuts([QKeySequence(QKeySequence.ZoomIn), QKeySequence("Ctrl+=")]) 56 self.bigger_action.triggered.connect(lambda: self.set_font_delta(self.font_delta + 1)) 57 self.smaller_action = QAction("Smaller Text", self) 58 self.smaller_action.setShortcut(QKeySequence(QKeySequence.ZoomOut)) 59 self.smaller_action.triggered.connect(lambda: self.set_font_delta(self.font_delta - 1)) 60 self.reset_font_action = QAction("Actual Size", self) # (Ctrl+0 is Graph → Fit to window) 61 self.reset_font_action.triggered.connect(lambda: self.set_font_delta(0)) 62 view_menu.addActions([self.bigger_action, self.smaller_action, self.reset_font_action]) 63 editor_mod.editor_font_family = self.settings.value("editor_font_family", "") or "" 64 editor_mod.editor_font_size = self.settings.value("editor_font_size", 0.0, type=float) or 0.0 65 self.editor_font_action = view_menu.addAction("Editor Font…") 66 self.editor_font_action.setStatusTip("Choose the font for the editor and the fpm.toml tab") 67 self.editor_font_action.triggered.connect(self.choose_editor_font) 68 self.reset_editor_font_action = view_menu.addAction("Reset Editor Font") 69 self.reset_editor_font_action.setStatusTip("Use the system's fixed-width font in the editor again") 70 self.reset_editor_font_action.triggered.connect(lambda: self.set_editor_font("", 0.0)) 71 self.reset_editor_font_action.setEnabled(bool(editor_mod.editor_font_family 72 or editor_mod.editor_font_size)) 73 view_menu.addSeparator() 74 self.use_editor_action = QAction("Open Files in Built-in Editor", self, checkable=True) 75 self.use_editor_action.setChecked(self.settings.value("use_editor", True, type=bool)) 76 self.use_editor_action.toggled.connect(lambda on: self.settings.setValue("use_editor", on)) 77 self.use_editor_action.setStatusTip( 78 "Double-clicked files open in a tab here; turn off to use your default application") 79 view_menu.addAction(self.use_editor_action) 80 self.lint_action = QAction("Lint Fortran Files (fortitude)", self, checkable=True) 81 EditorTab.lint_enabled = self.settings.value("lint", True, type=bool) 82 self.lint_action.setChecked(EditorTab.lint_enabled) 83 self.lint_action.toggled.connect(self.set_lint_enabled) 84 if lint.executable() is None: 85 self.lint_action.setEnabled(False) 86 self.lint_action.setText("Lint Fortran Files (fortitude not found)") 87 self.lint_action.setStatusTip("Check free-form Fortran files in the editor with fortitude " 88 "as you type; findings are underlined and marked in the gutter") 89 view_menu.addAction(self.lint_action) 90 self.lsp_diagnostics_action = QAction("Language Server Diagnostics (fortls)", self, checkable=True) 91 EditorTab.lsp_diagnostics_enabled = self.settings.value("lsp_diagnostics", True, type=bool) 92 self.lsp_diagnostics_action.setChecked(EditorTab.lsp_diagnostics_enabled) 93 self.lsp_diagnostics_action.toggled.connect(self.set_lsp_diagnostics_enabled) 94 self.lsp_diagnostics_action.setStatusTip( 95 "Show the problems fortls finds in a Fortran file when it's opened or saved (e.g. USE after " 96 "IMPLICIT, misplaced CONTAINS, interface mismatches), marked like fortitude's") 97 view_menu.addAction(self.lsp_diagnostics_action) 98 self.spelling_action = QAction("Check Spelling in Comments and Strings", self, checkable=True) 99 EditorTab.spelling_enabled = self.settings.value("spelling", True, type=bool) 100 spelling.user_words = {w for w in str(self.settings.value("spelling_words", "") or "").split()} 101 self.spelling_action.setChecked(EditorTab.spelling_enabled) 102 self.spelling_action.toggled.connect(self.set_spelling_enabled) 103 if not spelling.available(): 104 self.spelling_action.setEnabled(False) 105 self.spelling_action.setText("Check Spelling (pyspellchecker not found)") 106 self.spelling_action.setStatusTip( 107 "Underline misspelled words in comments and strings (right-click one for suggestions)") 108 view_menu.addSeparator() # (spelling: its two items together) 109 view_menu.addAction(self.spelling_action) 110 spelling.set_language(str(self.settings.value("spelling_language", "en") or "en")) 111 self.spelling_language_menu = view_menu.addMenu("Spelling Language") # (kept: PySide would let it go) 112 group = QActionGroup(self.spelling_language_menu) 113 codes = sorted(spelling.languages(), key=lambda c: spelling.LANGUAGE_NAMES.get(c, c)) 114 for code in codes: 115 action = self.spelling_language_menu.addAction(f"{spelling.LANGUAGE_NAMES.get(code, code)} ({code})") 116 action.setCheckable(True) 117 action.setChecked(code == spelling.language) 118 action.setData(code) 119 group.addAction(action) 120 action.triggered.connect(lambda _=False, c=code: self.set_spelling_language(c)) 121 self.spelling_language_menu.setEnabled(bool(codes)) 122 view_menu.addSeparator() 123 self.gremlins_action = QAction("Show Gremlins (Non-ASCII and Invisible Characters)", self, checkable=True) 124 EditorTab.gremlins_enabled = self.settings.value("gremlins", True, type=bool) 125 self.gremlins_action.setChecked(EditorTab.gremlins_enabled) 126 self.gremlins_action.toggled.connect(self.set_gremlins_enabled) 127 self.gremlins_action.setStatusTip( 128 "Mark lines with characters that aren't plain ASCII (accented letters, smart quotes, " 129 "non-breaking or zero-width spaces, control characters) with a gremlin in the gutter") 130 view_menu.addAction(self.gremlins_action) 131 self.minimap_action = QAction("Show Minimap", self, checkable=True) 132 EditorTab.minimap_enabled = self.settings.value("minimap", True, type=bool) 133 self.minimap_action.setChecked(EditorTab.minimap_enabled) 134 self.minimap_action.setStatusTip("A zoomed-out view of the whole file at the right of the editor") 135 self.minimap_action.toggled.connect(self.set_minimap_enabled) 136 view_menu.addAction(self.minimap_action) 137 self.blame_action = QAction("Show Git Blame", self, checkable=True) 138 EditorTab.blame_enabled = self.settings.value("git_blame", False, type=bool) 139 self.blame_action.setChecked(EditorTab.blame_enabled) 140 self.blame_action.setStatusTip("Beside the editor, each line's last commit (hash, author, date); " 141 "click one to open it on GitHub or GitLab") 142 self.blame_action.toggled.connect(self.set_blame_enabled) 143 view_menu.addAction(self.blame_action) 144 self.coverage_action = QAction("Show Coverage in the Editor", self, checkable=True) 145 CodeEditor.show_coverage = self.settings.value("show_coverage", True, type=bool) 146 self.coverage_action.setChecked(CodeEditor.show_coverage) 147 self.coverage_action.setStatusTip("After a test run with Build options → Coverage: green and red bars in the " 148 "gutter for the lines the tests ran and didn't") 149 self.coverage_action.toggled.connect(self.set_show_coverage) 150 view_menu.addAction(self.coverage_action) 151 self.matches_action = QAction("Highlight Other Occurrences of the Selection", self, checkable=True) 152 CodeEditor.highlight_matches = self.settings.value("highlight_matches", True, type=bool) 153 self.matches_action.setChecked(CodeEditor.highlight_matches) 154 self.matches_action.setStatusTip("Selecting text in the editor highlights it everywhere else in the file " 155 "(Match case and Whole words as set in the find bar)") 156 self.matches_action.toggled.connect(self.set_highlight_matches) 157 view_menu.addAction(self.matches_action) 158 self.word_wrap_action = QAction("Word Wrap", self, checkable=True) 159 self.word_wrap_action.setShortcut(QKeySequence("Alt+Z")) 160 EditorTab.word_wrap = self.settings.value("word_wrap", False, type=bool) 161 self.word_wrap_action.setChecked(EditorTab.word_wrap) 162 self.word_wrap_action.setStatusTip("Wrap long lines in the editor, the terminal and the output at the " 163 "window's edge instead of scrolling sideways") 164 self.word_wrap_action.toggled.connect(self.set_word_wrap) 165 view_menu.addAction(self.word_wrap_action) 166 # Indentation: what Tab types, and how wide tabs are shown. 167 width = self.settings.value("indent_width", 4, type=int) 168 CodeEditor.indent_width = width if width in INDENT_WIDTHS else 4 169 CodeEditor.use_tabs = self.settings.value("indent_tabs", False, type=bool) 170 self.indent_menu = view_menu.addMenu("Indentation") # (kept: PySide would let it go) 171 self.indent_actions: dict[object, QAction] = {} 172 kinds = QActionGroup(self.indent_menu) 173 for use_tabs, text, tip in ((False, "Indent with Spaces", "Tab types spaces, to the next tab stop"), 174 (True, "Indent with Tabs", "Tab types a tab character")): 175 action = self.indent_menu.addAction(text) 176 action.setCheckable(True) 177 action.setStatusTip(tip) 178 action.triggered.connect(lambda _=False, t=use_tabs: self.set_indentation(use_tabs=t)) 179 kinds.addAction(action) 180 self.indent_actions[use_tabs] = action 181 self.indent_menu.addSeparator() 182 widths = QActionGroup(self.indent_menu) 183 for w in INDENT_WIDTHS: 184 action = self.indent_menu.addAction(f"Width: {w}") 185 action.setCheckable(True) 186 action.setStatusTip(f"An indent level is {w} columns (tabs are shown {w} wide too)") 187 action.triggered.connect(lambda _=False, n=w: self.set_indentation(width=n)) 188 widths.addAction(action) 189 self.indent_actions[w] = action 190 self._check_indent_actions() 191 view_menu.addSeparator() 192 self.terminal_action = QAction("Terminal", self) 193 # ⌃` as in VS Code (the Control key on macOS too: ⌘` switches windows). 194 self.terminal_action.setShortcut(QKeySequence("Meta+`" if sys.platform == "darwin" else "Ctrl+`")) 195 self.terminal_action.setStatusTip("Your shell, in the project's folder (Terminal tab)") 196 self.terminal_action.triggered.connect(self.show_terminal) 197 view_menu.addAction(self.terminal_action) 198 self.fortls_action = QAction("Fortran Language Server (fortls)", self, checkable=True) 199 self.fortls_action.setChecked(self.settings.value("fortls", True, type=bool)) 200 self.fortls_action.toggled.connect(self.set_fortls_enabled) 201 if lsp.executable() is None: 202 self.fortls_action.setEnabled(False) 203 self.fortls_action.setText("Fortran Language Server (fortls not found)") 204 self.fortls_action.setStatusTip("Hover information, Go to Definition, the outline and " 205 "Go to Symbol for Fortran files, from fortls") 206 view_menu.addAction(self.fortls_action) 207 view_menu.addSeparator() 208 self.theme_menu = view_menu.addMenu("Theme") # (kept: PySide would let it go) 209 self.theme_menu.setStatusTip("The colors of the whole window (Syntax Colors are the editor's)") 210 self.theme_actions: dict[str, QAction] = {} 211 group = QActionGroup(self.theme_menu) 212 previous = None 213 for key, theme in themes.THEMES.items(): 214 kind = "native" if theme.native else "dark" if theme.dark else "light" 215 if previous is not None and kind != previous: 216 self.theme_menu.addSeparator() # (System/Light/Dark · dark colorful · light colorful) 217 previous = kind 218 action = self.theme_menu.addAction(theme.title) 219 action.setCheckable(True) 220 action.setChecked(key == themes.current) 221 action.triggered.connect(lambda _=False, k=key: self.set_theme(k)) 222 group.addAction(action) 223 self.theme_actions[key] = action 224 # The style that draws the window: the theme's, or one chosen. 225 self.window_style_menu = view_menu.addMenu("Window Style") # (kept: PySide would let it go) 226 self.window_style_menu.setStatusTip("How buttons, tabs and the panels' dividers are drawn: the " 227 "theme's choice, or one of Qt's styles") 228 self.window_style_menu.aboutToShow.connect(self._fill_window_style_menu) 229 # The toolbar: small icons, labels or not (also its right-click menu: see running._build_commands). 230 self.toolbar_menu = view_menu.addMenu("Toolbar") # (kept: PySide would let it go) 231 self.small_icons_action = self.toolbar_menu.addAction("Small Icons") 232 self.small_icons_action.setCheckable(True) 233 self.small_icons_action.setChecked(self.settings.value("toolbar_small_icons", False, type=bool)) 234 self.small_icons_action.setStatusTip("Smaller icons on the toolbar (the window's text size still counts)") 235 self.toolbar_labels_action = self.toolbar_menu.addAction("Show Labels") 236 self.toolbar_labels_action.setCheckable(True) 237 self.toolbar_labels_action.setChecked(self.settings.value("toolbar_labels", True, type=bool)) 238 self.toolbar_labels_action.setStatusTip("The commands' names under the toolbar's icons (off: icons only, " 239 "their names in the tooltips)") 240 self.small_icons_action.toggled.connect(lambda on: self._set_toolbar_option("toolbar_small_icons", on)) 241 self.toolbar_labels_action.toggled.connect(lambda on: self._set_toolbar_option("toolbar_labels", on)) 242 hl.chosen_style = self.settings.value("syntax_style", hl.AUTO) or hl.AUTO 243 self.style_menu = view_menu.addMenu("Syntax Colors") 244 self.style_menu.setStatusTip("The Pygments color style for the editor and fpm.toml") 245 self.style_menu.aboutToShow.connect(self._fill_style_menu) 246 247 def _apply_highlight_style(self): 248 """The chosen Pygments style, or one matching the system's light/dark mode.""" 249 for tree in getattr(self, "result_trees", []): 250 tree.refresh_style() 251 if hasattr(self, "debug_panel"): 252 self.debug_panel.breakpoints.refresh_style() 253 254 def _fill_style_menu(self): 255 """View → Syntax Colors: Automatic, then every installed Pygments style.""" 256 menu = self.style_menu 257 menu.clear() 258 group = QActionGroup(menu) 259 auto = menu.addAction(f"Automatic ({hl.LIGHT_STYLE} / {hl.DARK_STYLE}, following light or dark mode)") 260 auto.setData(hl.AUTO) 261 menu.addSeparator() 262 light, dark = hl.available_styles() 263 actions = [auto] 264 for title, names in (("Light Styles", light), ("Dark Styles", dark)): 265 sub = menu.addMenu(title) 266 for name in names: 267 action = sub.addAction(name) 268 action.setData(name) 269 actions.append(action) 270 for action in actions: 271 action.setCheckable(True) 272 action.setChecked(action.data() == hl.chosen_style) 273 group.addAction(action) 274 action.triggered.connect(lambda _checked=False, a=action: self.set_syntax_style(a.data())) 275 276 def show_command_palette(self): 277 """View → Command Palette…: the menus' commands, found by typing.""" 278 if self._command_palette is None: 279 recent = [str(t) for t in self.settings.value("palette_recent", [], type=list) or []] 280 self._command_palette = CommandPalette(self, recent) 281 self._command_palette.recent_changed = lambda titles: self.settings.setValue("palette_recent", titles) 282 # (Hidden: in no menu; shown only when its name is typed. See vibefpmgui.punch_card.) 283 self._command_palette.hidden = [Command(("Punch Card",), self.punch_card_action)] 284 if getattr(self, "_quick_open", None) is not None and self._quick_open.isVisible(): 285 self._quick_open.close() # (one palette at a time) 286 self._command_palette.set_icon(self.palette_action.icon()) # (in the theme's colors now) 287 self._command_palette.open() 288 289 def _fill_window_style_menu(self): 290 """View → Window Style: Automatic, then Qt's styles on this platform.""" 291 menu = self.window_style_menu 292 menu.clear() 293 group = QActionGroup(menu) 294 native = themes.native_style() 295 auto = menu.addAction(f"Automatic ({native} for System / Light / Dark, Fusion for the colorful themes)") 296 auto.setData(themes.AUTOMATIC) 297 menu.addSeparator() 298 actions = [auto] 299 for name in themes.styles(): 300 title = name + (" (this platform's)" if name.lower() == native.lower() else "") 301 action = menu.addAction(title) 302 action.setData(name) 303 if name.lower() == "fusion": 304 action.setStatusTip("Qt's own style, alike on every platform: the panels' dividers have a grip of " 305 "dots, and the colorful themes' colors show throughout") 306 elif name.lower() not in themes.PALETTE_STYLES: 307 action.setStatusTip(f"{name}: the colorful themes' colors don't show on everything with it " 308 "(it draws with the platform's own colors)") 309 actions.append(action) 310 chosen = (themes.chosen_style or "").lower() 311 for action in actions: 312 action.setCheckable(True) 313 action.setChecked((action.data() or "").lower() == chosen) 314 group.addAction(action) 315 action.triggered.connect(lambda _checked=False, a=action: self.set_window_style(a.data())) 316 317 def set_window_style(self, name: str): 318 """View → Window Style (themes.AUTOMATIC: the theme's).""" 319 themes.set_window_style(name) 320 self.settings.setValue("app_style", name) 321 self._apply_highlight_style() 322 for editor in self.editors(): 323 editor.refresh_style() 324 325 def set_theme(self, key: str): 326 """View → Theme: the whole window's colors (and, with Syntax Colors 327 on Automatic, the editor's).""" 328 themes.apply(key) 329 self.settings.setValue("app_theme", themes.current) 330 for k, action in self.theme_actions.items(): 331 action.setChecked(k == themes.current) 332 self._apply_highlight_style() # (the automatic syntax style may change with it) 333 for editor in self.editors(): 334 editor.refresh_style() 335 336 def set_syntax_style(self, name: str): 337 hl.chosen_style = name 338 self.settings.setValue("syntax_style", name) 339 self._apply_highlight_style() 340 for editor in self.editors(): 341 editor.refresh_style() 342 self.review_monitor.refresh_palette() # (code in the AI's answers: the same colors) 343 self.git_panel.refresh_palette() # (the diff's colors too) 344 345 def changeEvent(self, event): 346 if event.type() == QEvent.PaletteChange and hasattr(self, "terminal_panel"): # (the window is built) 347 self._apply_highlight_style() 348 self.graph_view.refresh_theme() # "Auto" theme follows the system 349 self._update_legend() 350 self.build_monitor.refresh_palette() 351 self.test_monitor.refresh_palette() 352 self.run_monitor.refresh_palette() 353 self.review_monitor.refresh_palette() 354 self.coverage_panel.refresh_palette() 355 self.git_panel.refresh_palette() 356 self.complexity_panel.refresh_palette() 357 self.debug_panel.refresh_palette() 358 self.outputs_view.refresh_palette() 359 self._draw_config_icons() 360 self.terminal_panel.refresh_colors() 361 for editor in self.editors(): 362 editor.refresh_style() 363 self.project_tree.refresh_marks() 364 self._update_command_icons() 365 for editor in self.editors(): 366 if getattr(editor, "close_button", None) is not None: 367 self._style_close_button(editor.close_button) 368 self.build_monitor.set_package_colors(self.graph_view.package_colors()) 369 if self.graph: # (the details are HTML: its links' color is the palette's when it's made) 370 self.show_package(self.graph_view.selected or self.graph.root) 371 super().changeEvent(event) 372 373 FONT_DELTA_RANGE = (-4, 12) # points relative to the system size 374 375 def _set_toolbar_option(self, key: str, on: bool): 376 self.settings.setValue(key, on) # (the app's setting, for every project: as the theme) 377 self.apply_toolbar_look() 378 379 def apply_toolbar_look(self): 380 """The toolbar's icons, small (16) or not (22, scaled with the text 381 size), with their labels under them or not (View → Toolbar).""" 382 if not hasattr(self, "command_toolbar"): 383 return 384 size = round((16 if self.small_icons_action.isChecked() else 22) * getattr(self, "_ui_factor", 1.0)) 385 self.command_toolbar.setIconSize(QSize(size, size)) 386 self.command_toolbar.setToolButtonStyle(Qt.ToolButtonTextUnderIcon if self.toolbar_labels_action.isChecked() 387 else Qt.ToolButtonIconOnly) 388 389 def set_font_delta(self, delta: int, announce: bool = True): 390 """Make all text in the window ``delta`` points bigger (or smaller) 391 than the system size.""" 392 lo, hi = self.FONT_DELTA_RANGE 393 self.font_delta = max(lo, min(hi, int(delta))) 394 base = self._base_font.pointSizeF() 395 size = base + self.font_delta 396 factor = size / base 397 set_ui_scale(factor) 398 399 font = QFont(self._base_font) 400 font.setPointSizeF(size) 401 QApplication.setFont(font) 402 # Widgets with their own fonts don't follow the application font. 403 mono = self._mono_font() 404 for view in (self.console, self.test_output): 405 view.setFont(mono) 406 code = self._mono_font(editor=True) # View → Editor Font 407 for editor in self.editors(): 408 editor.set_font(code) 409 if hasattr(self, "terminal_panel"): 410 self.terminal_panel.set_font(code) 411 if hasattr(self, "git_panel"): 412 self.git_panel.set_code_font(code) 413 if hasattr(self, "complexity_panel"): 414 self.complexity_panel.set_code_font(code) 415 self._ui_factor = factor 416 self.apply_toolbar_look() 417 self.build_monitor.refresh_fonts() 418 self.test_monitor.refresh_fonts() 419 self.run_monitor.refresh_fonts() 420 self.review_monitor.refresh_fonts() 421 self.coverage_panel.refresh_fonts() 422 self.git_panel.refresh_fonts() 423 self.complexity_panel.refresh_fonts() 424 for tree in self.result_trees: 425 tree.mono = mono 426 tree.doItemsLayout() 427 self.debug_panel.breakpoints.mono = mono 428 self.debug_panel.breakpoints.viewport().update() 429 self.graph_view.refresh_fonts() 430 if self.graph: 431 self.show_package(self.graph_view.selected or self.graph.root) 432 433 self.bigger_action.setEnabled(self.font_delta < hi) 434 self.smaller_action.setEnabled(self.font_delta > lo) 435 self.reset_font_action.setEnabled(self.font_delta != 0) 436 self.settings.setValue("font_delta", self.font_delta) 437 if announce: 438 self.statusBar().showMessage(f"Text size {round(factor * 100)}%", 3000) 439 440 def _mono_font(self, editor: bool = False) -> QFont: 441 base = self._base_font.pointSizeF() 442 return monospace_font((base + self.font_delta) / base, editor=editor) 443 444 def choose_editor_font(self): 445 """View → Editor Font…: pick a fixed-width family and size (at 100% 446 text size; Bigger/Smaller Text still scale it).""" 447 current = monospace_font(1.0, editor=True) 448 # Qt's own dialog: the macOS font panel ignores the fixed-width filter. 449 result = QFontDialog.getFont(current, self, "Editor Font", 450 QFontDialog.MonospacedFonts | QFontDialog.DontUseNativeDialog) 451 # PySide returns (ok, font), PyQt (font, ok). 452 ok, font = result if isinstance(result[0], bool) else (result[1], result[0]) 453 if ok: 454 self.set_editor_font(font.family(), font.pointSizeF()) 455 456 def set_editor_font(self, family: str, size: float): 457 system = QFontDatabase.systemFont(QFontDatabase.FixedFont) 458 if family == system.family() and abs(size - system.pointSizeF()) < 0.01: 459 family, size = "", 0.0 # the same as the default 460 editor_mod.editor_font_family, editor_mod.editor_font_size = family, size 461 self.settings.setValue("editor_font_family", family) 462 self.settings.setValue("editor_font_size", size) 463 self.reset_editor_font_action.setEnabled(bool(family or size)) 464 self.set_font_delta(self.font_delta, announce=False) # re-applies the fonts 465 shown = f"{family or system.family()} {round(size or system.pointSizeF(), 1):g} pt" 466 self.statusBar().showMessage(f"Editor font: {shown}", 4000) 467 468 def set_show_coverage(self, on: bool): 469 CodeEditor.show_coverage = on 470 self.settings.setValue("show_coverage", on) 471 for editor in self.editors(): 472 editor.editor.refresh_gutter() 473 474 def set_highlight_matches(self, on: bool): 475 CodeEditor.highlight_matches = on 476 self.settings.setValue("highlight_matches", on) 477 for editor in self.editors(): 478 editor.editor.update_occurrences() 479 480 def set_blame_enabled(self, on: bool): 481 EditorTab.blame_enabled = on 482 self.settings.setValue("git_blame", on) 483 for editor in self.editors(): 484 editor.set_blame_enabled(on) 485 486 def set_minimap_enabled(self, on: bool): 487 EditorTab.minimap_enabled = on 488 self.settings.setValue("minimap", on) 489 for editor in self.editors(): 490 editor.minimap.setVisible(on) 491 492 def set_indentation(self, width: int | None = None, use_tabs: bool | None = None): 493 """View → Indentation: spaces or tabs, and the width of an indent level.""" 494 cls = CodeEditor 495 cls.indent_width = cls.indent_width if width is None else width 496 cls.use_tabs = cls.use_tabs if use_tabs is None else use_tabs 497 self.settings.setValue("indent_width", cls.indent_width) 498 self.settings.setValue("indent_tabs", cls.use_tabs) 499 for editor in self.editors(): 500 editor.editor.set_indentation(cls.indent_width, cls.use_tabs) 501 editor.update_status() 502 self._check_indent_actions() 503 504 def _check_indent_actions(self): 505 cls = CodeEditor 506 for key, action in self.indent_actions.items(): 507 # (bool keys are spaces/tabs; ints are widths: True == 1 and False == 0 never collide with them) 508 action.setChecked(key is cls.use_tabs if isinstance(key, bool) else key == cls.indent_width) 509 510 def set_word_wrap(self, on: bool): 511 EditorTab.word_wrap = on 512 self.settings.setValue("word_wrap", on) 513 for editor in self.editors(): 514 editor.editor.set_word_wrap(on) 515 self.terminal_panel.set_word_wrap(on) 516 self._set_output_wrap(on) 517 518 def _set_output_wrap(self, on: bool): 519 """The Output panel's text: wrapped at its edge, or scrolled sideways.""" 520 mode = QPlainTextEdit.WidgetWidth if on else QPlainTextEdit.NoWrap 521 for text in (self.console, self.test_output): 522 text.setLineWrapMode(mode) 523 524 def set_lsp_diagnostics_enabled(self, on: bool): 525 EditorTab.lsp_diagnostics_enabled = on 526 self.settings.setValue("lsp_diagnostics", on) 527 for editor in self.editors(): 528 editor.show_lsp_diagnostics(self._lsp_diagnostics_by_file.get(editor.path, [])) 529 530 def set_spelling_enabled(self, on: bool): 531 EditorTab.spelling_enabled = on 532 self.settings.setValue("spelling", on) 533 for editor in self.editors(): 534 editor.set_spelling_enabled(on) 535 536 def set_gremlins_enabled(self, on: bool): 537 EditorTab.gremlins_enabled = on 538 self.settings.setValue("gremlins", on) 539 for editor in self.editors(): 540 editor.set_gremlins_enabled(on) 541 542 def set_spelling_language(self, code: str): 543 """View → Spelling Language: check with that language's dictionary.""" 544 spelling.set_language(code) 545 self.settings.setValue("spelling_language", spelling.language) 546 for action in self.spelling_language_menu.actions(): 547 action.setChecked(action.data() == spelling.language) 548 for editor in self.editors(): 549 editor.check_spelling() 550 551 def add_spelling_word(self, word: str): 552 """"Add to Dictionary": not a misspelling anymore, in any file.""" 553 spelling.user_words.add(word.lower()) 554 self.settings.setValue("spelling_words", " ".join(sorted(spelling.user_words))) 555 for editor in self.editors(): 556 editor.check_spelling() 557 self.statusBar().showMessage(f"Added “{word}” to the dictionary", 4000) 558 559 def set_lint_enabled(self, on: bool): 560 EditorTab.lint_enabled = on 561 self.settings.setValue("lint", on) 562 for editor in self.editors(): 563 editor.set_lint_enabled(on) 564 self._update_editor_actions() # (the Fix … Issues items) 565 566 def _build_help_menu(self): 567 """Help menu (added last, so it is the rightmost menu).""" 568 menu = self.menuBar().addMenu("&Help") 569 docs = menu.addAction("fpm Documentation") 570 docs.setStatusTip(FPM_DOCS_URL) 571 docs.triggered.connect(lambda: QDesktopServices.openUrl(QUrl(FPM_DOCS_URL))) 572 menu.addSeparator() 573 about = menu.addAction("About VibeFPMGUI") 574 about.setMenuRole(QAction.AboutRole) # macOS: moved to the application menu 575 about.triggered.connect(self.show_about) 576 self.about_qt_action = menu.addAction("About Qt") 577 self.about_qt_action.setMenuRole(QAction.AboutQtRole) # macOS: moved to the application menu 578 self.about_qt_action.setStatusTip("Qt's own About window: the Qt version and license") 579 self.about_qt_action.triggered.connect(lambda: QMessageBox.aboutQt(self, "About Qt")) 580 581 def show_about(self): 582 compiler, source = self.effective_compiler() 583 run_dialog(AboutDialog(self._fpm_executable(), compiler, source, self))
34class ViewMixin: 35 """The View menu and how the window looks (a part of `vibefpmgui.main_window.MainWindow`).""" 36 37 def _build_view_menu(self): 38 """View menu: text size, editor options, theme and colors (Panels: _build_docks).""" 39 # View menu: text size for the whole window. 40 # The system font, recorded once: later windows would otherwise see the 41 # already-enlarged application font as "normal". 42 app = QApplication.instance() 43 if app.property("vibefpm_base_font") is None: 44 app.setProperty("vibefpm_base_font", QFont(app.font())) 45 self._base_font = QFont(app.property("vibefpm_base_font")) 46 self.font_delta = 0 47 view_menu = self.view_menu = self.menuBar().addMenu("&View") 48 # Every command in the menus, found by typing (see vibefpmgui.command_palette). 49 self.palette_action = view_menu.addAction("Command Palette…") 50 self.palette_action.setShortcut(QKeySequence("Ctrl+Shift+P")) # (⌘⇧P on macOS) 51 self.palette_action.setStatusTip("Find any command in the menus by typing part of its name") 52 self.palette_action.triggered.connect(self.show_command_palette) 53 self._command_palette: CommandPalette | None = None 54 view_menu.addSeparator() 55 self.bigger_action = QAction("Bigger Text", self) 56 self.bigger_action.setShortcuts([QKeySequence(QKeySequence.ZoomIn), QKeySequence("Ctrl+=")]) 57 self.bigger_action.triggered.connect(lambda: self.set_font_delta(self.font_delta + 1)) 58 self.smaller_action = QAction("Smaller Text", self) 59 self.smaller_action.setShortcut(QKeySequence(QKeySequence.ZoomOut)) 60 self.smaller_action.triggered.connect(lambda: self.set_font_delta(self.font_delta - 1)) 61 self.reset_font_action = QAction("Actual Size", self) # (Ctrl+0 is Graph → Fit to window) 62 self.reset_font_action.triggered.connect(lambda: self.set_font_delta(0)) 63 view_menu.addActions([self.bigger_action, self.smaller_action, self.reset_font_action]) 64 editor_mod.editor_font_family = self.settings.value("editor_font_family", "") or "" 65 editor_mod.editor_font_size = self.settings.value("editor_font_size", 0.0, type=float) or 0.0 66 self.editor_font_action = view_menu.addAction("Editor Font…") 67 self.editor_font_action.setStatusTip("Choose the font for the editor and the fpm.toml tab") 68 self.editor_font_action.triggered.connect(self.choose_editor_font) 69 self.reset_editor_font_action = view_menu.addAction("Reset Editor Font") 70 self.reset_editor_font_action.setStatusTip("Use the system's fixed-width font in the editor again") 71 self.reset_editor_font_action.triggered.connect(lambda: self.set_editor_font("", 0.0)) 72 self.reset_editor_font_action.setEnabled(bool(editor_mod.editor_font_family 73 or editor_mod.editor_font_size)) 74 view_menu.addSeparator() 75 self.use_editor_action = QAction("Open Files in Built-in Editor", self, checkable=True) 76 self.use_editor_action.setChecked(self.settings.value("use_editor", True, type=bool)) 77 self.use_editor_action.toggled.connect(lambda on: self.settings.setValue("use_editor", on)) 78 self.use_editor_action.setStatusTip( 79 "Double-clicked files open in a tab here; turn off to use your default application") 80 view_menu.addAction(self.use_editor_action) 81 self.lint_action = QAction("Lint Fortran Files (fortitude)", self, checkable=True) 82 EditorTab.lint_enabled = self.settings.value("lint", True, type=bool) 83 self.lint_action.setChecked(EditorTab.lint_enabled) 84 self.lint_action.toggled.connect(self.set_lint_enabled) 85 if lint.executable() is None: 86 self.lint_action.setEnabled(False) 87 self.lint_action.setText("Lint Fortran Files (fortitude not found)") 88 self.lint_action.setStatusTip("Check free-form Fortran files in the editor with fortitude " 89 "as you type; findings are underlined and marked in the gutter") 90 view_menu.addAction(self.lint_action) 91 self.lsp_diagnostics_action = QAction("Language Server Diagnostics (fortls)", self, checkable=True) 92 EditorTab.lsp_diagnostics_enabled = self.settings.value("lsp_diagnostics", True, type=bool) 93 self.lsp_diagnostics_action.setChecked(EditorTab.lsp_diagnostics_enabled) 94 self.lsp_diagnostics_action.toggled.connect(self.set_lsp_diagnostics_enabled) 95 self.lsp_diagnostics_action.setStatusTip( 96 "Show the problems fortls finds in a Fortran file when it's opened or saved (e.g. USE after " 97 "IMPLICIT, misplaced CONTAINS, interface mismatches), marked like fortitude's") 98 view_menu.addAction(self.lsp_diagnostics_action) 99 self.spelling_action = QAction("Check Spelling in Comments and Strings", self, checkable=True) 100 EditorTab.spelling_enabled = self.settings.value("spelling", True, type=bool) 101 spelling.user_words = {w for w in str(self.settings.value("spelling_words", "") or "").split()} 102 self.spelling_action.setChecked(EditorTab.spelling_enabled) 103 self.spelling_action.toggled.connect(self.set_spelling_enabled) 104 if not spelling.available(): 105 self.spelling_action.setEnabled(False) 106 self.spelling_action.setText("Check Spelling (pyspellchecker not found)") 107 self.spelling_action.setStatusTip( 108 "Underline misspelled words in comments and strings (right-click one for suggestions)") 109 view_menu.addSeparator() # (spelling: its two items together) 110 view_menu.addAction(self.spelling_action) 111 spelling.set_language(str(self.settings.value("spelling_language", "en") or "en")) 112 self.spelling_language_menu = view_menu.addMenu("Spelling Language") # (kept: PySide would let it go) 113 group = QActionGroup(self.spelling_language_menu) 114 codes = sorted(spelling.languages(), key=lambda c: spelling.LANGUAGE_NAMES.get(c, c)) 115 for code in codes: 116 action = self.spelling_language_menu.addAction(f"{spelling.LANGUAGE_NAMES.get(code, code)} ({code})") 117 action.setCheckable(True) 118 action.setChecked(code == spelling.language) 119 action.setData(code) 120 group.addAction(action) 121 action.triggered.connect(lambda _=False, c=code: self.set_spelling_language(c)) 122 self.spelling_language_menu.setEnabled(bool(codes)) 123 view_menu.addSeparator() 124 self.gremlins_action = QAction("Show Gremlins (Non-ASCII and Invisible Characters)", self, checkable=True) 125 EditorTab.gremlins_enabled = self.settings.value("gremlins", True, type=bool) 126 self.gremlins_action.setChecked(EditorTab.gremlins_enabled) 127 self.gremlins_action.toggled.connect(self.set_gremlins_enabled) 128 self.gremlins_action.setStatusTip( 129 "Mark lines with characters that aren't plain ASCII (accented letters, smart quotes, " 130 "non-breaking or zero-width spaces, control characters) with a gremlin in the gutter") 131 view_menu.addAction(self.gremlins_action) 132 self.minimap_action = QAction("Show Minimap", self, checkable=True) 133 EditorTab.minimap_enabled = self.settings.value("minimap", True, type=bool) 134 self.minimap_action.setChecked(EditorTab.minimap_enabled) 135 self.minimap_action.setStatusTip("A zoomed-out view of the whole file at the right of the editor") 136 self.minimap_action.toggled.connect(self.set_minimap_enabled) 137 view_menu.addAction(self.minimap_action) 138 self.blame_action = QAction("Show Git Blame", self, checkable=True) 139 EditorTab.blame_enabled = self.settings.value("git_blame", False, type=bool) 140 self.blame_action.setChecked(EditorTab.blame_enabled) 141 self.blame_action.setStatusTip("Beside the editor, each line's last commit (hash, author, date); " 142 "click one to open it on GitHub or GitLab") 143 self.blame_action.toggled.connect(self.set_blame_enabled) 144 view_menu.addAction(self.blame_action) 145 self.coverage_action = QAction("Show Coverage in the Editor", self, checkable=True) 146 CodeEditor.show_coverage = self.settings.value("show_coverage", True, type=bool) 147 self.coverage_action.setChecked(CodeEditor.show_coverage) 148 self.coverage_action.setStatusTip("After a test run with Build options → Coverage: green and red bars in the " 149 "gutter for the lines the tests ran and didn't") 150 self.coverage_action.toggled.connect(self.set_show_coverage) 151 view_menu.addAction(self.coverage_action) 152 self.matches_action = QAction("Highlight Other Occurrences of the Selection", self, checkable=True) 153 CodeEditor.highlight_matches = self.settings.value("highlight_matches", True, type=bool) 154 self.matches_action.setChecked(CodeEditor.highlight_matches) 155 self.matches_action.setStatusTip("Selecting text in the editor highlights it everywhere else in the file " 156 "(Match case and Whole words as set in the find bar)") 157 self.matches_action.toggled.connect(self.set_highlight_matches) 158 view_menu.addAction(self.matches_action) 159 self.word_wrap_action = QAction("Word Wrap", self, checkable=True) 160 self.word_wrap_action.setShortcut(QKeySequence("Alt+Z")) 161 EditorTab.word_wrap = self.settings.value("word_wrap", False, type=bool) 162 self.word_wrap_action.setChecked(EditorTab.word_wrap) 163 self.word_wrap_action.setStatusTip("Wrap long lines in the editor, the terminal and the output at the " 164 "window's edge instead of scrolling sideways") 165 self.word_wrap_action.toggled.connect(self.set_word_wrap) 166 view_menu.addAction(self.word_wrap_action) 167 # Indentation: what Tab types, and how wide tabs are shown. 168 width = self.settings.value("indent_width", 4, type=int) 169 CodeEditor.indent_width = width if width in INDENT_WIDTHS else 4 170 CodeEditor.use_tabs = self.settings.value("indent_tabs", False, type=bool) 171 self.indent_menu = view_menu.addMenu("Indentation") # (kept: PySide would let it go) 172 self.indent_actions: dict[object, QAction] = {} 173 kinds = QActionGroup(self.indent_menu) 174 for use_tabs, text, tip in ((False, "Indent with Spaces", "Tab types spaces, to the next tab stop"), 175 (True, "Indent with Tabs", "Tab types a tab character")): 176 action = self.indent_menu.addAction(text) 177 action.setCheckable(True) 178 action.setStatusTip(tip) 179 action.triggered.connect(lambda _=False, t=use_tabs: self.set_indentation(use_tabs=t)) 180 kinds.addAction(action) 181 self.indent_actions[use_tabs] = action 182 self.indent_menu.addSeparator() 183 widths = QActionGroup(self.indent_menu) 184 for w in INDENT_WIDTHS: 185 action = self.indent_menu.addAction(f"Width: {w}") 186 action.setCheckable(True) 187 action.setStatusTip(f"An indent level is {w} columns (tabs are shown {w} wide too)") 188 action.triggered.connect(lambda _=False, n=w: self.set_indentation(width=n)) 189 widths.addAction(action) 190 self.indent_actions[w] = action 191 self._check_indent_actions() 192 view_menu.addSeparator() 193 self.terminal_action = QAction("Terminal", self) 194 # ⌃` as in VS Code (the Control key on macOS too: ⌘` switches windows). 195 self.terminal_action.setShortcut(QKeySequence("Meta+`" if sys.platform == "darwin" else "Ctrl+`")) 196 self.terminal_action.setStatusTip("Your shell, in the project's folder (Terminal tab)") 197 self.terminal_action.triggered.connect(self.show_terminal) 198 view_menu.addAction(self.terminal_action) 199 self.fortls_action = QAction("Fortran Language Server (fortls)", self, checkable=True) 200 self.fortls_action.setChecked(self.settings.value("fortls", True, type=bool)) 201 self.fortls_action.toggled.connect(self.set_fortls_enabled) 202 if lsp.executable() is None: 203 self.fortls_action.setEnabled(False) 204 self.fortls_action.setText("Fortran Language Server (fortls not found)") 205 self.fortls_action.setStatusTip("Hover information, Go to Definition, the outline and " 206 "Go to Symbol for Fortran files, from fortls") 207 view_menu.addAction(self.fortls_action) 208 view_menu.addSeparator() 209 self.theme_menu = view_menu.addMenu("Theme") # (kept: PySide would let it go) 210 self.theme_menu.setStatusTip("The colors of the whole window (Syntax Colors are the editor's)") 211 self.theme_actions: dict[str, QAction] = {} 212 group = QActionGroup(self.theme_menu) 213 previous = None 214 for key, theme in themes.THEMES.items(): 215 kind = "native" if theme.native else "dark" if theme.dark else "light" 216 if previous is not None and kind != previous: 217 self.theme_menu.addSeparator() # (System/Light/Dark · dark colorful · light colorful) 218 previous = kind 219 action = self.theme_menu.addAction(theme.title) 220 action.setCheckable(True) 221 action.setChecked(key == themes.current) 222 action.triggered.connect(lambda _=False, k=key: self.set_theme(k)) 223 group.addAction(action) 224 self.theme_actions[key] = action 225 # The style that draws the window: the theme's, or one chosen. 226 self.window_style_menu = view_menu.addMenu("Window Style") # (kept: PySide would let it go) 227 self.window_style_menu.setStatusTip("How buttons, tabs and the panels' dividers are drawn: the " 228 "theme's choice, or one of Qt's styles") 229 self.window_style_menu.aboutToShow.connect(self._fill_window_style_menu) 230 # The toolbar: small icons, labels or not (also its right-click menu: see running._build_commands). 231 self.toolbar_menu = view_menu.addMenu("Toolbar") # (kept: PySide would let it go) 232 self.small_icons_action = self.toolbar_menu.addAction("Small Icons") 233 self.small_icons_action.setCheckable(True) 234 self.small_icons_action.setChecked(self.settings.value("toolbar_small_icons", False, type=bool)) 235 self.small_icons_action.setStatusTip("Smaller icons on the toolbar (the window's text size still counts)") 236 self.toolbar_labels_action = self.toolbar_menu.addAction("Show Labels") 237 self.toolbar_labels_action.setCheckable(True) 238 self.toolbar_labels_action.setChecked(self.settings.value("toolbar_labels", True, type=bool)) 239 self.toolbar_labels_action.setStatusTip("The commands' names under the toolbar's icons (off: icons only, " 240 "their names in the tooltips)") 241 self.small_icons_action.toggled.connect(lambda on: self._set_toolbar_option("toolbar_small_icons", on)) 242 self.toolbar_labels_action.toggled.connect(lambda on: self._set_toolbar_option("toolbar_labels", on)) 243 hl.chosen_style = self.settings.value("syntax_style", hl.AUTO) or hl.AUTO 244 self.style_menu = view_menu.addMenu("Syntax Colors") 245 self.style_menu.setStatusTip("The Pygments color style for the editor and fpm.toml") 246 self.style_menu.aboutToShow.connect(self._fill_style_menu) 247 248 def _apply_highlight_style(self): 249 """The chosen Pygments style, or one matching the system's light/dark mode.""" 250 for tree in getattr(self, "result_trees", []): 251 tree.refresh_style() 252 if hasattr(self, "debug_panel"): 253 self.debug_panel.breakpoints.refresh_style() 254 255 def _fill_style_menu(self): 256 """View → Syntax Colors: Automatic, then every installed Pygments style.""" 257 menu = self.style_menu 258 menu.clear() 259 group = QActionGroup(menu) 260 auto = menu.addAction(f"Automatic ({hl.LIGHT_STYLE} / {hl.DARK_STYLE}, following light or dark mode)") 261 auto.setData(hl.AUTO) 262 menu.addSeparator() 263 light, dark = hl.available_styles() 264 actions = [auto] 265 for title, names in (("Light Styles", light), ("Dark Styles", dark)): 266 sub = menu.addMenu(title) 267 for name in names: 268 action = sub.addAction(name) 269 action.setData(name) 270 actions.append(action) 271 for action in actions: 272 action.setCheckable(True) 273 action.setChecked(action.data() == hl.chosen_style) 274 group.addAction(action) 275 action.triggered.connect(lambda _checked=False, a=action: self.set_syntax_style(a.data())) 276 277 def show_command_palette(self): 278 """View → Command Palette…: the menus' commands, found by typing.""" 279 if self._command_palette is None: 280 recent = [str(t) for t in self.settings.value("palette_recent", [], type=list) or []] 281 self._command_palette = CommandPalette(self, recent) 282 self._command_palette.recent_changed = lambda titles: self.settings.setValue("palette_recent", titles) 283 # (Hidden: in no menu; shown only when its name is typed. See vibefpmgui.punch_card.) 284 self._command_palette.hidden = [Command(("Punch Card",), self.punch_card_action)] 285 if getattr(self, "_quick_open", None) is not None and self._quick_open.isVisible(): 286 self._quick_open.close() # (one palette at a time) 287 self._command_palette.set_icon(self.palette_action.icon()) # (in the theme's colors now) 288 self._command_palette.open() 289 290 def _fill_window_style_menu(self): 291 """View → Window Style: Automatic, then Qt's styles on this platform.""" 292 menu = self.window_style_menu 293 menu.clear() 294 group = QActionGroup(menu) 295 native = themes.native_style() 296 auto = menu.addAction(f"Automatic ({native} for System / Light / Dark, Fusion for the colorful themes)") 297 auto.setData(themes.AUTOMATIC) 298 menu.addSeparator() 299 actions = [auto] 300 for name in themes.styles(): 301 title = name + (" (this platform's)" if name.lower() == native.lower() else "") 302 action = menu.addAction(title) 303 action.setData(name) 304 if name.lower() == "fusion": 305 action.setStatusTip("Qt's own style, alike on every platform: the panels' dividers have a grip of " 306 "dots, and the colorful themes' colors show throughout") 307 elif name.lower() not in themes.PALETTE_STYLES: 308 action.setStatusTip(f"{name}: the colorful themes' colors don't show on everything with it " 309 "(it draws with the platform's own colors)") 310 actions.append(action) 311 chosen = (themes.chosen_style or "").lower() 312 for action in actions: 313 action.setCheckable(True) 314 action.setChecked((action.data() or "").lower() == chosen) 315 group.addAction(action) 316 action.triggered.connect(lambda _checked=False, a=action: self.set_window_style(a.data())) 317 318 def set_window_style(self, name: str): 319 """View → Window Style (themes.AUTOMATIC: the theme's).""" 320 themes.set_window_style(name) 321 self.settings.setValue("app_style", name) 322 self._apply_highlight_style() 323 for editor in self.editors(): 324 editor.refresh_style() 325 326 def set_theme(self, key: str): 327 """View → Theme: the whole window's colors (and, with Syntax Colors 328 on Automatic, the editor's).""" 329 themes.apply(key) 330 self.settings.setValue("app_theme", themes.current) 331 for k, action in self.theme_actions.items(): 332 action.setChecked(k == themes.current) 333 self._apply_highlight_style() # (the automatic syntax style may change with it) 334 for editor in self.editors(): 335 editor.refresh_style() 336 337 def set_syntax_style(self, name: str): 338 hl.chosen_style = name 339 self.settings.setValue("syntax_style", name) 340 self._apply_highlight_style() 341 for editor in self.editors(): 342 editor.refresh_style() 343 self.review_monitor.refresh_palette() # (code in the AI's answers: the same colors) 344 self.git_panel.refresh_palette() # (the diff's colors too) 345 346 def changeEvent(self, event): 347 if event.type() == QEvent.PaletteChange and hasattr(self, "terminal_panel"): # (the window is built) 348 self._apply_highlight_style() 349 self.graph_view.refresh_theme() # "Auto" theme follows the system 350 self._update_legend() 351 self.build_monitor.refresh_palette() 352 self.test_monitor.refresh_palette() 353 self.run_monitor.refresh_palette() 354 self.review_monitor.refresh_palette() 355 self.coverage_panel.refresh_palette() 356 self.git_panel.refresh_palette() 357 self.complexity_panel.refresh_palette() 358 self.debug_panel.refresh_palette() 359 self.outputs_view.refresh_palette() 360 self._draw_config_icons() 361 self.terminal_panel.refresh_colors() 362 for editor in self.editors(): 363 editor.refresh_style() 364 self.project_tree.refresh_marks() 365 self._update_command_icons() 366 for editor in self.editors(): 367 if getattr(editor, "close_button", None) is not None: 368 self._style_close_button(editor.close_button) 369 self.build_monitor.set_package_colors(self.graph_view.package_colors()) 370 if self.graph: # (the details are HTML: its links' color is the palette's when it's made) 371 self.show_package(self.graph_view.selected or self.graph.root) 372 super().changeEvent(event) 373 374 FONT_DELTA_RANGE = (-4, 12) # points relative to the system size 375 376 def _set_toolbar_option(self, key: str, on: bool): 377 self.settings.setValue(key, on) # (the app's setting, for every project: as the theme) 378 self.apply_toolbar_look() 379 380 def apply_toolbar_look(self): 381 """The toolbar's icons, small (16) or not (22, scaled with the text 382 size), with their labels under them or not (View → Toolbar).""" 383 if not hasattr(self, "command_toolbar"): 384 return 385 size = round((16 if self.small_icons_action.isChecked() else 22) * getattr(self, "_ui_factor", 1.0)) 386 self.command_toolbar.setIconSize(QSize(size, size)) 387 self.command_toolbar.setToolButtonStyle(Qt.ToolButtonTextUnderIcon if self.toolbar_labels_action.isChecked() 388 else Qt.ToolButtonIconOnly) 389 390 def set_font_delta(self, delta: int, announce: bool = True): 391 """Make all text in the window ``delta`` points bigger (or smaller) 392 than the system size.""" 393 lo, hi = self.FONT_DELTA_RANGE 394 self.font_delta = max(lo, min(hi, int(delta))) 395 base = self._base_font.pointSizeF() 396 size = base + self.font_delta 397 factor = size / base 398 set_ui_scale(factor) 399 400 font = QFont(self._base_font) 401 font.setPointSizeF(size) 402 QApplication.setFont(font) 403 # Widgets with their own fonts don't follow the application font. 404 mono = self._mono_font() 405 for view in (self.console, self.test_output): 406 view.setFont(mono) 407 code = self._mono_font(editor=True) # View → Editor Font 408 for editor in self.editors(): 409 editor.set_font(code) 410 if hasattr(self, "terminal_panel"): 411 self.terminal_panel.set_font(code) 412 if hasattr(self, "git_panel"): 413 self.git_panel.set_code_font(code) 414 if hasattr(self, "complexity_panel"): 415 self.complexity_panel.set_code_font(code) 416 self._ui_factor = factor 417 self.apply_toolbar_look() 418 self.build_monitor.refresh_fonts() 419 self.test_monitor.refresh_fonts() 420 self.run_monitor.refresh_fonts() 421 self.review_monitor.refresh_fonts() 422 self.coverage_panel.refresh_fonts() 423 self.git_panel.refresh_fonts() 424 self.complexity_panel.refresh_fonts() 425 for tree in self.result_trees: 426 tree.mono = mono 427 tree.doItemsLayout() 428 self.debug_panel.breakpoints.mono = mono 429 self.debug_panel.breakpoints.viewport().update() 430 self.graph_view.refresh_fonts() 431 if self.graph: 432 self.show_package(self.graph_view.selected or self.graph.root) 433 434 self.bigger_action.setEnabled(self.font_delta < hi) 435 self.smaller_action.setEnabled(self.font_delta > lo) 436 self.reset_font_action.setEnabled(self.font_delta != 0) 437 self.settings.setValue("font_delta", self.font_delta) 438 if announce: 439 self.statusBar().showMessage(f"Text size {round(factor * 100)}%", 3000) 440 441 def _mono_font(self, editor: bool = False) -> QFont: 442 base = self._base_font.pointSizeF() 443 return monospace_font((base + self.font_delta) / base, editor=editor) 444 445 def choose_editor_font(self): 446 """View → Editor Font…: pick a fixed-width family and size (at 100% 447 text size; Bigger/Smaller Text still scale it).""" 448 current = monospace_font(1.0, editor=True) 449 # Qt's own dialog: the macOS font panel ignores the fixed-width filter. 450 result = QFontDialog.getFont(current, self, "Editor Font", 451 QFontDialog.MonospacedFonts | QFontDialog.DontUseNativeDialog) 452 # PySide returns (ok, font), PyQt (font, ok). 453 ok, font = result if isinstance(result[0], bool) else (result[1], result[0]) 454 if ok: 455 self.set_editor_font(font.family(), font.pointSizeF()) 456 457 def set_editor_font(self, family: str, size: float): 458 system = QFontDatabase.systemFont(QFontDatabase.FixedFont) 459 if family == system.family() and abs(size - system.pointSizeF()) < 0.01: 460 family, size = "", 0.0 # the same as the default 461 editor_mod.editor_font_family, editor_mod.editor_font_size = family, size 462 self.settings.setValue("editor_font_family", family) 463 self.settings.setValue("editor_font_size", size) 464 self.reset_editor_font_action.setEnabled(bool(family or size)) 465 self.set_font_delta(self.font_delta, announce=False) # re-applies the fonts 466 shown = f"{family or system.family()} {round(size or system.pointSizeF(), 1):g} pt" 467 self.statusBar().showMessage(f"Editor font: {shown}", 4000) 468 469 def set_show_coverage(self, on: bool): 470 CodeEditor.show_coverage = on 471 self.settings.setValue("show_coverage", on) 472 for editor in self.editors(): 473 editor.editor.refresh_gutter() 474 475 def set_highlight_matches(self, on: bool): 476 CodeEditor.highlight_matches = on 477 self.settings.setValue("highlight_matches", on) 478 for editor in self.editors(): 479 editor.editor.update_occurrences() 480 481 def set_blame_enabled(self, on: bool): 482 EditorTab.blame_enabled = on 483 self.settings.setValue("git_blame", on) 484 for editor in self.editors(): 485 editor.set_blame_enabled(on) 486 487 def set_minimap_enabled(self, on: bool): 488 EditorTab.minimap_enabled = on 489 self.settings.setValue("minimap", on) 490 for editor in self.editors(): 491 editor.minimap.setVisible(on) 492 493 def set_indentation(self, width: int | None = None, use_tabs: bool | None = None): 494 """View → Indentation: spaces or tabs, and the width of an indent level.""" 495 cls = CodeEditor 496 cls.indent_width = cls.indent_width if width is None else width 497 cls.use_tabs = cls.use_tabs if use_tabs is None else use_tabs 498 self.settings.setValue("indent_width", cls.indent_width) 499 self.settings.setValue("indent_tabs", cls.use_tabs) 500 for editor in self.editors(): 501 editor.editor.set_indentation(cls.indent_width, cls.use_tabs) 502 editor.update_status() 503 self._check_indent_actions() 504 505 def _check_indent_actions(self): 506 cls = CodeEditor 507 for key, action in self.indent_actions.items(): 508 # (bool keys are spaces/tabs; ints are widths: True == 1 and False == 0 never collide with them) 509 action.setChecked(key is cls.use_tabs if isinstance(key, bool) else key == cls.indent_width) 510 511 def set_word_wrap(self, on: bool): 512 EditorTab.word_wrap = on 513 self.settings.setValue("word_wrap", on) 514 for editor in self.editors(): 515 editor.editor.set_word_wrap(on) 516 self.terminal_panel.set_word_wrap(on) 517 self._set_output_wrap(on) 518 519 def _set_output_wrap(self, on: bool): 520 """The Output panel's text: wrapped at its edge, or scrolled sideways.""" 521 mode = QPlainTextEdit.WidgetWidth if on else QPlainTextEdit.NoWrap 522 for text in (self.console, self.test_output): 523 text.setLineWrapMode(mode) 524 525 def set_lsp_diagnostics_enabled(self, on: bool): 526 EditorTab.lsp_diagnostics_enabled = on 527 self.settings.setValue("lsp_diagnostics", on) 528 for editor in self.editors(): 529 editor.show_lsp_diagnostics(self._lsp_diagnostics_by_file.get(editor.path, [])) 530 531 def set_spelling_enabled(self, on: bool): 532 EditorTab.spelling_enabled = on 533 self.settings.setValue("spelling", on) 534 for editor in self.editors(): 535 editor.set_spelling_enabled(on) 536 537 def set_gremlins_enabled(self, on: bool): 538 EditorTab.gremlins_enabled = on 539 self.settings.setValue("gremlins", on) 540 for editor in self.editors(): 541 editor.set_gremlins_enabled(on) 542 543 def set_spelling_language(self, code: str): 544 """View → Spelling Language: check with that language's dictionary.""" 545 spelling.set_language(code) 546 self.settings.setValue("spelling_language", spelling.language) 547 for action in self.spelling_language_menu.actions(): 548 action.setChecked(action.data() == spelling.language) 549 for editor in self.editors(): 550 editor.check_spelling() 551 552 def add_spelling_word(self, word: str): 553 """"Add to Dictionary": not a misspelling anymore, in any file.""" 554 spelling.user_words.add(word.lower()) 555 self.settings.setValue("spelling_words", " ".join(sorted(spelling.user_words))) 556 for editor in self.editors(): 557 editor.check_spelling() 558 self.statusBar().showMessage(f"Added “{word}” to the dictionary", 4000) 559 560 def set_lint_enabled(self, on: bool): 561 EditorTab.lint_enabled = on 562 self.settings.setValue("lint", on) 563 for editor in self.editors(): 564 editor.set_lint_enabled(on) 565 self._update_editor_actions() # (the Fix … Issues items) 566 567 def _build_help_menu(self): 568 """Help menu (added last, so it is the rightmost menu).""" 569 menu = self.menuBar().addMenu("&Help") 570 docs = menu.addAction("fpm Documentation") 571 docs.setStatusTip(FPM_DOCS_URL) 572 docs.triggered.connect(lambda: QDesktopServices.openUrl(QUrl(FPM_DOCS_URL))) 573 menu.addSeparator() 574 about = menu.addAction("About VibeFPMGUI") 575 about.setMenuRole(QAction.AboutRole) # macOS: moved to the application menu 576 about.triggered.connect(self.show_about) 577 self.about_qt_action = menu.addAction("About Qt") 578 self.about_qt_action.setMenuRole(QAction.AboutQtRole) # macOS: moved to the application menu 579 self.about_qt_action.setStatusTip("Qt's own About window: the Qt version and license") 580 self.about_qt_action.triggered.connect(lambda: QMessageBox.aboutQt(self, "About Qt")) 581 582 def show_about(self): 583 compiler, source = self.effective_compiler() 584 run_dialog(AboutDialog(self._fpm_executable(), compiler, source, self))
The View menu and how the window looks (a part of vibefpmgui.main_window.MainWindow).
277 def show_command_palette(self): 278 """View → Command Palette…: the menus' commands, found by typing.""" 279 if self._command_palette is None: 280 recent = [str(t) for t in self.settings.value("palette_recent", [], type=list) or []] 281 self._command_palette = CommandPalette(self, recent) 282 self._command_palette.recent_changed = lambda titles: self.settings.setValue("palette_recent", titles) 283 # (Hidden: in no menu; shown only when its name is typed. See vibefpmgui.punch_card.) 284 self._command_palette.hidden = [Command(("Punch Card",), self.punch_card_action)] 285 if getattr(self, "_quick_open", None) is not None and self._quick_open.isVisible(): 286 self._quick_open.close() # (one palette at a time) 287 self._command_palette.set_icon(self.palette_action.icon()) # (in the theme's colors now) 288 self._command_palette.open()
View → Command Palette…: the menus' commands, found by typing.
318 def set_window_style(self, name: str): 319 """View → Window Style (themes.AUTOMATIC: the theme's).""" 320 themes.set_window_style(name) 321 self.settings.setValue("app_style", name) 322 self._apply_highlight_style() 323 for editor in self.editors(): 324 editor.refresh_style()
View → Window Style (themes.AUTOMATIC: the theme's).
326 def set_theme(self, key: str): 327 """View → Theme: the whole window's colors (and, with Syntax Colors 328 on Automatic, the editor's).""" 329 themes.apply(key) 330 self.settings.setValue("app_theme", themes.current) 331 for k, action in self.theme_actions.items(): 332 action.setChecked(k == themes.current) 333 self._apply_highlight_style() # (the automatic syntax style may change with it) 334 for editor in self.editors(): 335 editor.refresh_style()
View → Theme: the whole window's colors (and, with Syntax Colors on Automatic, the editor's).
337 def set_syntax_style(self, name: str): 338 hl.chosen_style = name 339 self.settings.setValue("syntax_style", name) 340 self._apply_highlight_style() 341 for editor in self.editors(): 342 editor.refresh_style() 343 self.review_monitor.refresh_palette() # (code in the AI's answers: the same colors) 344 self.git_panel.refresh_palette() # (the diff's colors too)
346 def changeEvent(self, event): 347 if event.type() == QEvent.PaletteChange and hasattr(self, "terminal_panel"): # (the window is built) 348 self._apply_highlight_style() 349 self.graph_view.refresh_theme() # "Auto" theme follows the system 350 self._update_legend() 351 self.build_monitor.refresh_palette() 352 self.test_monitor.refresh_palette() 353 self.run_monitor.refresh_palette() 354 self.review_monitor.refresh_palette() 355 self.coverage_panel.refresh_palette() 356 self.git_panel.refresh_palette() 357 self.complexity_panel.refresh_palette() 358 self.debug_panel.refresh_palette() 359 self.outputs_view.refresh_palette() 360 self._draw_config_icons() 361 self.terminal_panel.refresh_colors() 362 for editor in self.editors(): 363 editor.refresh_style() 364 self.project_tree.refresh_marks() 365 self._update_command_icons() 366 for editor in self.editors(): 367 if getattr(editor, "close_button", None) is not None: 368 self._style_close_button(editor.close_button) 369 self.build_monitor.set_package_colors(self.graph_view.package_colors()) 370 if self.graph: # (the details are HTML: its links' color is the palette's when it's made) 371 self.show_package(self.graph_view.selected or self.graph.root) 372 super().changeEvent(event)
380 def apply_toolbar_look(self): 381 """The toolbar's icons, small (16) or not (22, scaled with the text 382 size), with their labels under them or not (View → Toolbar).""" 383 if not hasattr(self, "command_toolbar"): 384 return 385 size = round((16 if self.small_icons_action.isChecked() else 22) * getattr(self, "_ui_factor", 1.0)) 386 self.command_toolbar.setIconSize(QSize(size, size)) 387 self.command_toolbar.setToolButtonStyle(Qt.ToolButtonTextUnderIcon if self.toolbar_labels_action.isChecked() 388 else Qt.ToolButtonIconOnly)
The toolbar's icons, small (16) or not (22, scaled with the text size), with their labels under them or not (View → Toolbar).
390 def set_font_delta(self, delta: int, announce: bool = True): 391 """Make all text in the window ``delta`` points bigger (or smaller) 392 than the system size.""" 393 lo, hi = self.FONT_DELTA_RANGE 394 self.font_delta = max(lo, min(hi, int(delta))) 395 base = self._base_font.pointSizeF() 396 size = base + self.font_delta 397 factor = size / base 398 set_ui_scale(factor) 399 400 font = QFont(self._base_font) 401 font.setPointSizeF(size) 402 QApplication.setFont(font) 403 # Widgets with their own fonts don't follow the application font. 404 mono = self._mono_font() 405 for view in (self.console, self.test_output): 406 view.setFont(mono) 407 code = self._mono_font(editor=True) # View → Editor Font 408 for editor in self.editors(): 409 editor.set_font(code) 410 if hasattr(self, "terminal_panel"): 411 self.terminal_panel.set_font(code) 412 if hasattr(self, "git_panel"): 413 self.git_panel.set_code_font(code) 414 if hasattr(self, "complexity_panel"): 415 self.complexity_panel.set_code_font(code) 416 self._ui_factor = factor 417 self.apply_toolbar_look() 418 self.build_monitor.refresh_fonts() 419 self.test_monitor.refresh_fonts() 420 self.run_monitor.refresh_fonts() 421 self.review_monitor.refresh_fonts() 422 self.coverage_panel.refresh_fonts() 423 self.git_panel.refresh_fonts() 424 self.complexity_panel.refresh_fonts() 425 for tree in self.result_trees: 426 tree.mono = mono 427 tree.doItemsLayout() 428 self.debug_panel.breakpoints.mono = mono 429 self.debug_panel.breakpoints.viewport().update() 430 self.graph_view.refresh_fonts() 431 if self.graph: 432 self.show_package(self.graph_view.selected or self.graph.root) 433 434 self.bigger_action.setEnabled(self.font_delta < hi) 435 self.smaller_action.setEnabled(self.font_delta > lo) 436 self.reset_font_action.setEnabled(self.font_delta != 0) 437 self.settings.setValue("font_delta", self.font_delta) 438 if announce: 439 self.statusBar().showMessage(f"Text size {round(factor * 100)}%", 3000)
Make all text in the window delta points bigger (or smaller)
than the system size.
445 def choose_editor_font(self): 446 """View → Editor Font…: pick a fixed-width family and size (at 100% 447 text size; Bigger/Smaller Text still scale it).""" 448 current = monospace_font(1.0, editor=True) 449 # Qt's own dialog: the macOS font panel ignores the fixed-width filter. 450 result = QFontDialog.getFont(current, self, "Editor Font", 451 QFontDialog.MonospacedFonts | QFontDialog.DontUseNativeDialog) 452 # PySide returns (ok, font), PyQt (font, ok). 453 ok, font = result if isinstance(result[0], bool) else (result[1], result[0]) 454 if ok: 455 self.set_editor_font(font.family(), font.pointSizeF())
View → Editor Font…: pick a fixed-width family and size (at 100% text size; Bigger/Smaller Text still scale it).
457 def set_editor_font(self, family: str, size: float): 458 system = QFontDatabase.systemFont(QFontDatabase.FixedFont) 459 if family == system.family() and abs(size - system.pointSizeF()) < 0.01: 460 family, size = "", 0.0 # the same as the default 461 editor_mod.editor_font_family, editor_mod.editor_font_size = family, size 462 self.settings.setValue("editor_font_family", family) 463 self.settings.setValue("editor_font_size", size) 464 self.reset_editor_font_action.setEnabled(bool(family or size)) 465 self.set_font_delta(self.font_delta, announce=False) # re-applies the fonts 466 shown = f"{family or system.family()} {round(size or system.pointSizeF(), 1):g} pt" 467 self.statusBar().showMessage(f"Editor font: {shown}", 4000)
493 def set_indentation(self, width: int | None = None, use_tabs: bool | None = None): 494 """View → Indentation: spaces or tabs, and the width of an indent level.""" 495 cls = CodeEditor 496 cls.indent_width = cls.indent_width if width is None else width 497 cls.use_tabs = cls.use_tabs if use_tabs is None else use_tabs 498 self.settings.setValue("indent_width", cls.indent_width) 499 self.settings.setValue("indent_tabs", cls.use_tabs) 500 for editor in self.editors(): 501 editor.editor.set_indentation(cls.indent_width, cls.use_tabs) 502 editor.update_status() 503 self._check_indent_actions()
View → Indentation: spaces or tabs, and the width of an indent level.
543 def set_spelling_language(self, code: str): 544 """View → Spelling Language: check with that language's dictionary.""" 545 spelling.set_language(code) 546 self.settings.setValue("spelling_language", spelling.language) 547 for action in self.spelling_language_menu.actions(): 548 action.setChecked(action.data() == spelling.language) 549 for editor in self.editors(): 550 editor.check_spelling()
View → Spelling Language: check with that language's dictionary.
552 def add_spelling_word(self, word: str): 553 """"Add to Dictionary": not a misspelling anymore, in any file.""" 554 spelling.user_words.add(word.lower()) 555 self.settings.setValue("spelling_words", " ".join(sorted(spelling.user_words))) 556 for editor in self.editors(): 557 editor.check_spelling() 558 self.statusBar().showMessage(f"Added “{word}” to the dictionary", 4000)
"Add to Dictionary": not a misspelling anymore, in any file.