vibefpmgui.window.editing
The Edit menu, and the commands that rewrite a file (format, fix, line endings).
1"""The Edit menu, and the commands that rewrite a file (format, fix, line endings).""" 2 3from __future__ import annotations 4 5import sys 6 7from qtpy.QtGui import QAction, QKeySequence 8from qtpy.QtWidgets import QApplication, QMessageBox 9 10from .. import formatter, line_edits, lint 11from ..background import call_in_background 12from ..editor import EditorTab 13from ..punch_card import PunchCardDialog 14from ..quick_open import QuickOpen 15from ..widgets import run_dialog 16 17 18class EditingMixin: 19 """The Edit menu and the commands that rewrite a file (a part of `vibefpmgui.main_window.MainWindow`).""" 20 21 def _build_edit_menu(self): 22 menu = self.menuBar().addMenu("&Edit") 23 24 def focused(method): 25 """Apply to the widget with focus (an editor, a text field…).""" 26 def run(): 27 widget = QApplication.focusWidget() 28 if widget is not None and hasattr(widget, method): 29 getattr(widget, method)() 30 elif self.current_editor() is not None: 31 getattr(self.current_editor().editor, method)() 32 return run 33 34 for item in [("Undo", QKeySequence.Undo, "undo"), 35 ("Redo", QKeySequence.Redo, "redo"), None, 36 ("Cut", QKeySequence.Cut, "cut"), 37 ("Copy", QKeySequence.Copy, "copy"), 38 ("Paste", QKeySequence.Paste, "paste"), 39 ("Select All", QKeySequence.SelectAll, "selectAll")]: 40 if item is None: 41 menu.addSeparator() 42 continue 43 text, key, method = item 44 action = menu.addAction(text) 45 action.setShortcut(QKeySequence(key)) 46 action.triggered.connect(focused(method)) 47 menu.addSeparator() 48 49 def editor_action(text, key, slot, into=None): 50 action = (into or menu).addAction(text) 51 action.setShortcut(QKeySequence(key)) 52 action.triggered.connect(lambda: self._with_editor(slot)) 53 return action 54 55 # Find, Go To and fortitude's fixes in submenus (the menu was getting long). 56 find_menu = self.find_menu = menu.addMenu("Find") # (kept: PySide would let it go) 57 self.find_action = editor_action("Find…", QKeySequence.Find, lambda e: e.find_bar.open_bar(), find_menu) 58 # With the dependency graph in front, Find goes to its package search. 59 self.find_action.triggered.disconnect() 60 self.find_action.triggered.connect(self._find) 61 self.replace_action = editor_action("Find and Replace…", "Ctrl+Alt+F", 62 lambda e: e.find_bar.open_bar(replace=True), find_menu) 63 self.find_next_action = editor_action("Find Next", QKeySequence.FindNext, 64 lambda e: e.find_bar.find_next(), find_menu) 65 self.find_previous_action = editor_action("Find Previous", QKeySequence.FindPrevious, 66 lambda e: e.find_bar.find_previous(), find_menu) 67 find_menu.addSeparator() 68 self.find_files_action = find_menu.addAction("Find in Files…") 69 self.find_files_action.setShortcut(QKeySequence("Ctrl+Shift+F")) 70 self.find_files_action.setStatusTip("Search all the files of the project and its dependencies " 71 "(Search tab)") 72 self.find_files_action.triggered.connect(lambda: self.find_in_files()) 73 self.replace_files_action = find_menu.addAction("Replace in Files…") 74 self.replace_files_action.setShortcut(QKeySequence("Ctrl+Shift+H")) 75 self.replace_files_action.setStatusTip("Search and replace across the project and its " 76 "dependencies (Search tab)") 77 self.replace_files_action.triggered.connect(lambda: self.find_in_files(replace=True)) 78 go_menu = self.go_menu = menu.addMenu("Go To") # (kept: PySide would let it go) 79 # ⌘P (Ctrl+P): any file of the project or its dependencies, by name (see vibefpmgui.quick_open). 80 self.go_to_file_action = go_menu.addAction("Go to File…") 81 self.go_to_file_action.setShortcut(QKeySequence("Ctrl+P")) 82 self.go_to_file_action.setStatusTip("Open any file of the project or its dependencies by typing part of " 83 "its name (git-ignored ones: with Project files → Ignored ticked)") 84 self.go_to_file_action.triggered.connect(self.go_to_file) 85 self._quick_open = None 86 self.goto_action = editor_action("Go to Line…", "Ctrl+L", lambda e: e.go_to_line_dialog(), go_menu) 87 self.symbol_action = go_menu.addAction("Go to Symbol in Project…") 88 self.symbol_action.setShortcut(QKeySequence("Ctrl+T")) 89 self.symbol_action.setStatusTip("Find a module, type or procedure in the project " 90 "and its dependencies") 91 self.symbol_action.triggered.connect(self.go_to_symbol) 92 go_menu.addSeparator() 93 self.definition_action = editor_action("Go to Definition", "F12", lambda e: e.go_to_definition(), go_menu) 94 self.definition_action.setStatusTip("Open the definition of the name at the cursor " 95 "(or ⌘/Ctrl-click the name)") 96 self.implementation_action = editor_action("Go to Implementation", "Ctrl+F12", 97 lambda e: e.go_to_implementation(), go_menu) 98 self.implementation_action.setStatusTip("For a type-bound procedure, open the procedure it binds; for a " 99 "generic interface, its first module procedure") 100 go_menu.addSeparator() 101 self.back_action = go_menu.addAction("Go Back") 102 self.back_action.setShortcut(QKeySequence(QKeySequence.Back)) 103 self.back_action.triggered.connect(lambda: self._go_history(back=True)) 104 self.forward_action = go_menu.addAction("Go Forward") 105 self.forward_action.setShortcut(QKeySequence(QKeySequence.Forward)) 106 self.forward_action.triggered.connect(lambda: self._go_history(back=False)) 107 self._back: list[tuple[str, int, int]] = [] # where Go to Definition jumped from 108 self._forward: list[tuple[str, int, int]] = [] 109 go_menu.addSeparator() 110 self.next_missed_action = editor_action("Next Missed Line", "F6", lambda e: self._go_to_missed(e, True), 111 go_menu) 112 self.next_missed_action.setStatusTip("Go to the next lines the last test run didn't run " 113 "(Build options → Coverage)") 114 self.previous_missed_action = editor_action("Previous Missed Line", "Shift+F6", 115 lambda e: self._go_to_missed(e, False), go_menu) 116 self.previous_missed_action.setStatusTip("Go to the previous lines the last test run didn't run") 117 menu.addSeparator() 118 self.references_action = editor_action("Find References", "Shift+F12", lambda e: e.find_references()) 119 self.references_action.setStatusTip("List every use of the name at the cursor (References tab)") 120 self.rename_action = editor_action("Rename Symbol…", "F2", lambda e: e.rename_symbol()) 121 self.rename_action.setStatusTip("Rename the name at the cursor everywhere it is used " 122 "(with a preview)") 123 # ⌃Space on macOS (⌘Space is Spotlight), Ctrl+Space elsewhere. 124 self.complete_action = editor_action("Show Completions", 125 "Meta+Space" if sys.platform == "darwin" else "Ctrl+Space", 126 lambda e: e.request_completion(manual=True)) 127 self.complete_action.setStatusTip("Offer completions for the name being typed " 128 "(they also appear by themselves as you type)") 129 menu.addSeparator() 130 # Lines and selections (see vibefpmgui.line_edits). Ctrl+/ is ⌘/ on macOS. 131 self.comment_action = editor_action("Toggle Line Comment", "Ctrl+/", lambda e: e.editor.toggle_comment()) 132 self.comment_action.setStatusTip("Comment out the selected lines (with ! in Fortran, # in fpm.toml), " 133 "or uncomment them if they all are") 134 lines_menu = self.lines_menu = menu.addMenu("Lines") # (kept: PySide would let it go) 135 self.move_up_action = editor_action("Move Line Up", "Alt+Up", lambda e: e.editor.move_lines(down=False), 136 lines_menu) 137 self.move_down_action = editor_action("Move Line Down", "Alt+Down", 138 lambda e: e.editor.move_lines(down=True), lines_menu) 139 for action in (self.move_up_action, self.move_down_action): 140 action.setStatusTip("Move the selected lines (or the cursor's) past the line above or below") 141 lines_menu.addSeparator() 142 self.sort_up_action = editor_action("Sort Lines Ascending", "", 143 lambda e: e.editor.sort_selected_lines(descending=False), lines_menu) 144 self.sort_down_action = editor_action("Sort Lines Descending", "", 145 lambda e: e.editor.sort_selected_lines(descending=True), lines_menu) 146 for action in (self.sort_up_action, self.sort_down_action): 147 action.setStatusTip("Sort the selected lines, ignoring case (select two or more lines)") 148 # A hidden command (in no menu: typed in the Command Palette, see window.view). 149 self.punch_card_action = QAction("Punch Card", self) 150 self.punch_card_action.setStatusTip("The line at the cursor as an IBM punched card") 151 self.punch_card_action.triggered.connect(lambda: self._with_editor(self.show_punch_card)) 152 lines_menu.addSeparator() 153 self.align_cursors_action = editor_action("Align Cursors", "", self.align_cursors, lines_menu) 154 self.align_cursors_action.setStatusTip( 155 "Line the cursors up in the column of the right-most one, with spaces before those further left " 156 + ("(several cursors: ⌥-click, or ⌘⇧-click for one on each line)" if sys.platform == "darwin" 157 else "(several cursors: Alt-click, or Ctrl+Shift-click for one on each line)")) 158 case_menu = self.case_menu = menu.addMenu("Change Case") # (kept: PySide would let it go) 159 for case, text in ((line_edits.UPPER, "UPPER CASE"), (line_edits.LOWER, "lower case"), 160 (line_edits.TITLE, "Title Case")): 161 action = editor_action(text, "", lambda e, c=case: e.editor.change_selection_case(c), case_menu) 162 action.setStatusTip(f"Make the selection (or the word at the cursor) {text}") 163 menu.addSeparator() 164 self.format_action = editor_action("Format with fprettify", "Ctrl+Shift+I", self.format_file) 165 self.format_action.setIconText("Format") 166 keys = self.format_action.shortcut().toString(QKeySequence.NativeText) 167 self.format_action.setToolTip(f"Format ({keys}) — tidy the Fortran file in the editor " 168 "with fprettify (undo to revert)") 169 self.format_action.setStatusTip("Re-indent and re-space the Fortran file in the editor " 170 "with fprettify") 171 fix_menu = self.fix_menu = menu.addMenu("Fix Issues (fortitude)") # (kept: PySide would let it go) 172 self.fix_safe_action = editor_action("Fix All Safe Issues", "", 173 lambda e: self.fix_lint_issues(e, unsafe=False), fix_menu) 174 self.fix_safe_action.setStatusTip("Apply fortitude's safe fixes (they don't change what the " 175 "code does) to the file in the editor; Undo reverts") 176 self.fix_all_action = editor_action("Fix All Issues, Including Unsafe", "", 177 lambda e: self.fix_lint_issues(e, unsafe=True), fix_menu) 178 self.fix_all_action.setStatusTip("Also apply fortitude's unsafe fixes, which can change what the " 179 "code does: check them before saving; Undo reverts") 180 menu.addSeparator() 181 self.trim_action = editor_action("Trim Trailing Whitespace", "", self.trim_trailing_whitespace) 182 self.trim_action.setStatusTip("Remove the spaces and tabs at the ends of the lines of the file in the " 183 "editor (Undo reverts)") 184 self.trim_on_save_action = QAction("Trim Trailing Whitespace on Save", self, checkable=True) 185 EditorTab.trim_on_save = self.settings.value("trim_on_save", True, type=bool) 186 self.trim_on_save_action.setChecked(EditorTab.trim_on_save) 187 self.trim_on_save_action.setStatusTip("Remove the spaces and tabs at the ends of the lines whenever a " 188 "file is saved (not Markdown or patch files, where they matter)") 189 self.trim_on_save_action.toggled.connect(self.set_trim_on_save) 190 menu.addAction(self.trim_on_save_action) 191 self.line_endings_menu = menu.addMenu("Convert Line Endings") # (kept: PySide would let it go) 192 self.line_ending_actions: dict[str, QAction] = {} 193 for newline, text, tip in (("\n", "To Unix Line Endings (LF)", "macOS and Linux"), 194 ("\r\n", "To Windows Line Endings (CRLF)", "Windows")): 195 action = self.line_endings_menu.addAction(text) 196 action.setCheckable(True) 197 action.setStatusTip(f"Save every line of the file in the editor with {tip}'s line ending") 198 action.triggered.connect(lambda _=False, n=newline: self.convert_line_endings(n)) 199 self.line_ending_actions[newline] = action 200 self.line_endings_menu.aboutToShow.connect(self._check_line_endings) 201 202 def go_to_file(self): 203 """Edit → Go To → Go to File… (⌘P): the files the Project files tree 204 lists (its Ignored checkbox respected), found by typing.""" 205 if self.manifest_path is None: 206 self.statusBar().showMessage("Go to File: open a project first", 4000) 207 return 208 tree = self.project_tree 209 if self._quick_open is None: 210 self._quick_open = QuickOpen(self, lambda path: self.open_file(path, force_editor=True), 211 tree.icon_for) 212 if self._command_palette is not None and self._command_palette.isVisible(): 213 self._command_palette.close() # (one palette at a time) 214 open_paths = [str(e.path) for e in self.editors() if not e.untitled] 215 self._quick_open.set_icon(self.go_to_file_action.icon()) # (in the theme's colors now) 216 self._quick_open.open(tree.all_files(), open_paths, tree.include_ignored.isChecked()) 217 218 def show_punch_card(self, editor: EditorTab): 219 """The hidden Punch Card command: the line at the cursor as an IBM card.""" 220 lines = editor.editor.toPlainText().split("\n") 221 name = "a new file" if editor.untitled else editor.path.name 222 run_dialog(PunchCardDialog(lines, editor.editor.textCursor().blockNumber(), name, self)) 223 224 def align_cursors(self, editor: EditorTab): 225 """Edit → Lines → Align Cursors (see CodeEditor.align_cursors).""" 226 if not editor.editor.align_cursors(): 227 self.statusBar().showMessage( 228 "Align Cursors: needs two or more cursors — " 229 + ("⌥-click to add one, ⌘⇧-click for one on each line" if sys.platform == "darwin" 230 else "Alt-click to add one, Ctrl+Shift-click for one on each line"), 6000) 231 232 def trim_trailing_whitespace(self, editor: EditorTab): 233 trimmed = editor.trim_trailing_whitespace() 234 lines = f"{trimmed} line{'s' * (trimmed != 1)}" if trimmed else "no lines" 235 self.statusBar().showMessage(f"{editor.path.name}: trailing whitespace trimmed from {lines}", 4000) 236 237 def set_trim_on_save(self, on: bool): 238 EditorTab.trim_on_save = on 239 self.settings.setValue("trim_on_save", on) 240 241 def _graph_in_use(self) -> bool: 242 """Find goes to the graph's search box: the graph panel has the focus, 243 or it's shown and no file is open.""" 244 dock = self.docks["graph"] 245 focus = QApplication.focusWidget() 246 if focus is not None and dock.isAncestorOf(focus): 247 return True 248 return self.current_editor() is None and self.panel_in_front(self._content(dock)) 249 250 def _find(self): 251 if self._graph_in_use(): 252 self.show_panel(self._content(self.docks["graph"])) 253 self.graph_view.focus_search() 254 else: 255 self._with_editor(lambda e: e.find_bar.open_bar()) 256 257 def _update_editor_actions(self, *_): 258 has_editor = self.current_editor() is not None 259 for action in (self.save_action, self.save_as_action, self.close_tab_action, 260 self.find_action, self.replace_action, self.find_next_action, 261 self.find_previous_action, self.goto_action, self.next_missed_action, 262 self.previous_missed_action): 263 action.setEnabled(has_editor) 264 graph = getattr(self, "docks", {}).get("graph") # (the panels exist) 265 self.find_action.setEnabled(has_editor or (graph is not None and self.panel_in_front(self._content(graph)))) 266 editor = self.current_editor() 267 self.format_action.setEnabled(editor is not None and formatter.can_format(editor.path) 268 and formatter.executable() is not None) 269 for action in (self.fix_safe_action, self.fix_all_action): 270 action.setEnabled(editor is not None and editor.can_lint()) 271 self.line_endings_menu.setEnabled(editor is not None) 272 self.comment_action.setEnabled(editor is not None and bool(editor.editor.comment_prefix)) 273 for submenu in (self.lines_menu, self.case_menu): 274 submenu.setEnabled(editor is not None) 275 self.trim_action.setEnabled(editor is not None) 276 self.punch_card_action.setEnabled(editor is not None) 277 for action in (self.definition_action, self.implementation_action, self.references_action, 278 self.rename_action, self.complete_action): 279 action.setEnabled(editor is not None and editor.lsp is not None) 280 self.symbol_action.setEnabled(self.lsp_client.is_running()) 281 self.back_action.setEnabled(bool(self._back)) 282 self.forward_action.setEnabled(bool(self._forward)) 283 self._update_review_actions() 284 285 def _rewrite_text(self, editor: EditorTab, doing: str, rewrite, done): 286 """Run ``rewrite(text) -> (new text or None, error)`` (a program: it can 287 take seconds on a big file) on a pool thread, then ``done(text, new, 288 error)`` in the GUI thread, if the file is still open and unchanged 289 (otherwise the result would undo what was typed meanwhile).""" 290 if editor.rewriting: 291 self.statusBar().showMessage(f"{editor.path.name}: still {doing}…", 3000) 292 return 293 editor.rewriting = True 294 text = editor.editor.toPlainText() 295 self.statusBar().showMessage(f"{doing.capitalize()} {editor.path.name}…") 296 297 def finished(result): 298 editor.rewriting = False 299 if editor not in self.editors(): 300 return # (closed meanwhile) 301 if editor.editor.toPlainText() != text: 302 self.statusBar().showMessage(f"{editor.path.name} changed while {doing}: nothing was changed " 303 "(try again)", 6000) 304 return 305 done(text, *result) 306 307 call_in_background(lambda: rewrite(text), finished, self) 308 309 def convert_line_endings(self, newline: str): 310 """Edit → Convert Line Endings: the whole file, saved with these from now on.""" 311 editor = self.current_editor() 312 if editor is None: 313 return 314 kind = "Unix (LF)" if newline == "\n" else "Windows (CRLF)" 315 if editor.convert_line_endings(newline): 316 self.statusBar().showMessage(f"{editor.path.name}: {kind} line endings (not saved yet)", 5000) 317 else: 318 self.statusBar().showMessage(f"{editor.path.name} already has {kind} line endings", 4000) 319 self._check_line_endings() 320 321 def _check_line_endings(self): 322 """Tick the current file's line ending in the menu.""" 323 editor = self.current_editor() 324 for newline, action in self.line_ending_actions.items(): 325 action.setChecked(editor is not None and editor.newline == newline and not editor.mixed_newlines) 326 327 def fix_lint_issues(self, editor: EditorTab, unsafe: bool): 328 """Edit → Fix Issues (fortitude) → Fix All … Issues: fortitude --fix on the editor's text (one 329 Undo step), run in the background.""" 330 if not editor.can_lint(): 331 return 332 expected = sum(1 for i in editor.editor.lint_issues if i.fixable and (unsafe or i.fix_safe)) 333 path = editor.path 334 335 def done(text, fixed, error): 336 if fixed is None: 337 QMessageBox.warning(self, "fortitude", f"{path.name} could not be fixed:\n\n{error}") 338 elif fixed == text: 339 kind = "fixable" if unsafe else "safely fixable" 340 self.statusBar().showMessage(f"{path.name}: no {kind} issues", 4000) 341 else: 342 editor.replace_text(fixed) 343 count = f"{expected} " if expected else "" 344 self.statusBar().showMessage(f"Applied {count}fortitude fix{'es' * (expected != 1)} to {path.name} " 345 "(Undo to revert; not saved yet)", 6000) 346 347 self._rewrite_text(editor, "fixing", lambda text: lint.fix_text(text, path, unsafe=unsafe), done) 348 349 def format_file(self, editor: EditorTab): 350 """Format the editor's (Fortran) text with fprettify, in the background.""" 351 if not formatter.can_format(editor.path): 352 return 353 path = editor.path 354 355 def done(text, formatted, error): 356 if formatted is None: 357 QMessageBox.warning(self, "fprettify failed", f"{path.name} could not be formatted:\n\n{error}") 358 elif formatted == text: 359 self.statusBar().showMessage(f"{path.name} is already formatted", 4000) 360 else: 361 editor.replace_text(formatted) 362 self.statusBar().showMessage(f"Formatted {path.name} with fprettify " 363 "(Undo to revert; not saved yet)", 6000) 364 365 self._rewrite_text(editor, "formatting", lambda text: formatter.format_text(text, path), done)
class
EditingMixin:
19class EditingMixin: 20 """The Edit menu and the commands that rewrite a file (a part of `vibefpmgui.main_window.MainWindow`).""" 21 22 def _build_edit_menu(self): 23 menu = self.menuBar().addMenu("&Edit") 24 25 def focused(method): 26 """Apply to the widget with focus (an editor, a text field…).""" 27 def run(): 28 widget = QApplication.focusWidget() 29 if widget is not None and hasattr(widget, method): 30 getattr(widget, method)() 31 elif self.current_editor() is not None: 32 getattr(self.current_editor().editor, method)() 33 return run 34 35 for item in [("Undo", QKeySequence.Undo, "undo"), 36 ("Redo", QKeySequence.Redo, "redo"), None, 37 ("Cut", QKeySequence.Cut, "cut"), 38 ("Copy", QKeySequence.Copy, "copy"), 39 ("Paste", QKeySequence.Paste, "paste"), 40 ("Select All", QKeySequence.SelectAll, "selectAll")]: 41 if item is None: 42 menu.addSeparator() 43 continue 44 text, key, method = item 45 action = menu.addAction(text) 46 action.setShortcut(QKeySequence(key)) 47 action.triggered.connect(focused(method)) 48 menu.addSeparator() 49 50 def editor_action(text, key, slot, into=None): 51 action = (into or menu).addAction(text) 52 action.setShortcut(QKeySequence(key)) 53 action.triggered.connect(lambda: self._with_editor(slot)) 54 return action 55 56 # Find, Go To and fortitude's fixes in submenus (the menu was getting long). 57 find_menu = self.find_menu = menu.addMenu("Find") # (kept: PySide would let it go) 58 self.find_action = editor_action("Find…", QKeySequence.Find, lambda e: e.find_bar.open_bar(), find_menu) 59 # With the dependency graph in front, Find goes to its package search. 60 self.find_action.triggered.disconnect() 61 self.find_action.triggered.connect(self._find) 62 self.replace_action = editor_action("Find and Replace…", "Ctrl+Alt+F", 63 lambda e: e.find_bar.open_bar(replace=True), find_menu) 64 self.find_next_action = editor_action("Find Next", QKeySequence.FindNext, 65 lambda e: e.find_bar.find_next(), find_menu) 66 self.find_previous_action = editor_action("Find Previous", QKeySequence.FindPrevious, 67 lambda e: e.find_bar.find_previous(), find_menu) 68 find_menu.addSeparator() 69 self.find_files_action = find_menu.addAction("Find in Files…") 70 self.find_files_action.setShortcut(QKeySequence("Ctrl+Shift+F")) 71 self.find_files_action.setStatusTip("Search all the files of the project and its dependencies " 72 "(Search tab)") 73 self.find_files_action.triggered.connect(lambda: self.find_in_files()) 74 self.replace_files_action = find_menu.addAction("Replace in Files…") 75 self.replace_files_action.setShortcut(QKeySequence("Ctrl+Shift+H")) 76 self.replace_files_action.setStatusTip("Search and replace across the project and its " 77 "dependencies (Search tab)") 78 self.replace_files_action.triggered.connect(lambda: self.find_in_files(replace=True)) 79 go_menu = self.go_menu = menu.addMenu("Go To") # (kept: PySide would let it go) 80 # ⌘P (Ctrl+P): any file of the project or its dependencies, by name (see vibefpmgui.quick_open). 81 self.go_to_file_action = go_menu.addAction("Go to File…") 82 self.go_to_file_action.setShortcut(QKeySequence("Ctrl+P")) 83 self.go_to_file_action.setStatusTip("Open any file of the project or its dependencies by typing part of " 84 "its name (git-ignored ones: with Project files → Ignored ticked)") 85 self.go_to_file_action.triggered.connect(self.go_to_file) 86 self._quick_open = None 87 self.goto_action = editor_action("Go to Line…", "Ctrl+L", lambda e: e.go_to_line_dialog(), go_menu) 88 self.symbol_action = go_menu.addAction("Go to Symbol in Project…") 89 self.symbol_action.setShortcut(QKeySequence("Ctrl+T")) 90 self.symbol_action.setStatusTip("Find a module, type or procedure in the project " 91 "and its dependencies") 92 self.symbol_action.triggered.connect(self.go_to_symbol) 93 go_menu.addSeparator() 94 self.definition_action = editor_action("Go to Definition", "F12", lambda e: e.go_to_definition(), go_menu) 95 self.definition_action.setStatusTip("Open the definition of the name at the cursor " 96 "(or ⌘/Ctrl-click the name)") 97 self.implementation_action = editor_action("Go to Implementation", "Ctrl+F12", 98 lambda e: e.go_to_implementation(), go_menu) 99 self.implementation_action.setStatusTip("For a type-bound procedure, open the procedure it binds; for a " 100 "generic interface, its first module procedure") 101 go_menu.addSeparator() 102 self.back_action = go_menu.addAction("Go Back") 103 self.back_action.setShortcut(QKeySequence(QKeySequence.Back)) 104 self.back_action.triggered.connect(lambda: self._go_history(back=True)) 105 self.forward_action = go_menu.addAction("Go Forward") 106 self.forward_action.setShortcut(QKeySequence(QKeySequence.Forward)) 107 self.forward_action.triggered.connect(lambda: self._go_history(back=False)) 108 self._back: list[tuple[str, int, int]] = [] # where Go to Definition jumped from 109 self._forward: list[tuple[str, int, int]] = [] 110 go_menu.addSeparator() 111 self.next_missed_action = editor_action("Next Missed Line", "F6", lambda e: self._go_to_missed(e, True), 112 go_menu) 113 self.next_missed_action.setStatusTip("Go to the next lines the last test run didn't run " 114 "(Build options → Coverage)") 115 self.previous_missed_action = editor_action("Previous Missed Line", "Shift+F6", 116 lambda e: self._go_to_missed(e, False), go_menu) 117 self.previous_missed_action.setStatusTip("Go to the previous lines the last test run didn't run") 118 menu.addSeparator() 119 self.references_action = editor_action("Find References", "Shift+F12", lambda e: e.find_references()) 120 self.references_action.setStatusTip("List every use of the name at the cursor (References tab)") 121 self.rename_action = editor_action("Rename Symbol…", "F2", lambda e: e.rename_symbol()) 122 self.rename_action.setStatusTip("Rename the name at the cursor everywhere it is used " 123 "(with a preview)") 124 # ⌃Space on macOS (⌘Space is Spotlight), Ctrl+Space elsewhere. 125 self.complete_action = editor_action("Show Completions", 126 "Meta+Space" if sys.platform == "darwin" else "Ctrl+Space", 127 lambda e: e.request_completion(manual=True)) 128 self.complete_action.setStatusTip("Offer completions for the name being typed " 129 "(they also appear by themselves as you type)") 130 menu.addSeparator() 131 # Lines and selections (see vibefpmgui.line_edits). Ctrl+/ is ⌘/ on macOS. 132 self.comment_action = editor_action("Toggle Line Comment", "Ctrl+/", lambda e: e.editor.toggle_comment()) 133 self.comment_action.setStatusTip("Comment out the selected lines (with ! in Fortran, # in fpm.toml), " 134 "or uncomment them if they all are") 135 lines_menu = self.lines_menu = menu.addMenu("Lines") # (kept: PySide would let it go) 136 self.move_up_action = editor_action("Move Line Up", "Alt+Up", lambda e: e.editor.move_lines(down=False), 137 lines_menu) 138 self.move_down_action = editor_action("Move Line Down", "Alt+Down", 139 lambda e: e.editor.move_lines(down=True), lines_menu) 140 for action in (self.move_up_action, self.move_down_action): 141 action.setStatusTip("Move the selected lines (or the cursor's) past the line above or below") 142 lines_menu.addSeparator() 143 self.sort_up_action = editor_action("Sort Lines Ascending", "", 144 lambda e: e.editor.sort_selected_lines(descending=False), lines_menu) 145 self.sort_down_action = editor_action("Sort Lines Descending", "", 146 lambda e: e.editor.sort_selected_lines(descending=True), lines_menu) 147 for action in (self.sort_up_action, self.sort_down_action): 148 action.setStatusTip("Sort the selected lines, ignoring case (select two or more lines)") 149 # A hidden command (in no menu: typed in the Command Palette, see window.view). 150 self.punch_card_action = QAction("Punch Card", self) 151 self.punch_card_action.setStatusTip("The line at the cursor as an IBM punched card") 152 self.punch_card_action.triggered.connect(lambda: self._with_editor(self.show_punch_card)) 153 lines_menu.addSeparator() 154 self.align_cursors_action = editor_action("Align Cursors", "", self.align_cursors, lines_menu) 155 self.align_cursors_action.setStatusTip( 156 "Line the cursors up in the column of the right-most one, with spaces before those further left " 157 + ("(several cursors: ⌥-click, or ⌘⇧-click for one on each line)" if sys.platform == "darwin" 158 else "(several cursors: Alt-click, or Ctrl+Shift-click for one on each line)")) 159 case_menu = self.case_menu = menu.addMenu("Change Case") # (kept: PySide would let it go) 160 for case, text in ((line_edits.UPPER, "UPPER CASE"), (line_edits.LOWER, "lower case"), 161 (line_edits.TITLE, "Title Case")): 162 action = editor_action(text, "", lambda e, c=case: e.editor.change_selection_case(c), case_menu) 163 action.setStatusTip(f"Make the selection (or the word at the cursor) {text}") 164 menu.addSeparator() 165 self.format_action = editor_action("Format with fprettify", "Ctrl+Shift+I", self.format_file) 166 self.format_action.setIconText("Format") 167 keys = self.format_action.shortcut().toString(QKeySequence.NativeText) 168 self.format_action.setToolTip(f"Format ({keys}) — tidy the Fortran file in the editor " 169 "with fprettify (undo to revert)") 170 self.format_action.setStatusTip("Re-indent and re-space the Fortran file in the editor " 171 "with fprettify") 172 fix_menu = self.fix_menu = menu.addMenu("Fix Issues (fortitude)") # (kept: PySide would let it go) 173 self.fix_safe_action = editor_action("Fix All Safe Issues", "", 174 lambda e: self.fix_lint_issues(e, unsafe=False), fix_menu) 175 self.fix_safe_action.setStatusTip("Apply fortitude's safe fixes (they don't change what the " 176 "code does) to the file in the editor; Undo reverts") 177 self.fix_all_action = editor_action("Fix All Issues, Including Unsafe", "", 178 lambda e: self.fix_lint_issues(e, unsafe=True), fix_menu) 179 self.fix_all_action.setStatusTip("Also apply fortitude's unsafe fixes, which can change what the " 180 "code does: check them before saving; Undo reverts") 181 menu.addSeparator() 182 self.trim_action = editor_action("Trim Trailing Whitespace", "", self.trim_trailing_whitespace) 183 self.trim_action.setStatusTip("Remove the spaces and tabs at the ends of the lines of the file in the " 184 "editor (Undo reverts)") 185 self.trim_on_save_action = QAction("Trim Trailing Whitespace on Save", self, checkable=True) 186 EditorTab.trim_on_save = self.settings.value("trim_on_save", True, type=bool) 187 self.trim_on_save_action.setChecked(EditorTab.trim_on_save) 188 self.trim_on_save_action.setStatusTip("Remove the spaces and tabs at the ends of the lines whenever a " 189 "file is saved (not Markdown or patch files, where they matter)") 190 self.trim_on_save_action.toggled.connect(self.set_trim_on_save) 191 menu.addAction(self.trim_on_save_action) 192 self.line_endings_menu = menu.addMenu("Convert Line Endings") # (kept: PySide would let it go) 193 self.line_ending_actions: dict[str, QAction] = {} 194 for newline, text, tip in (("\n", "To Unix Line Endings (LF)", "macOS and Linux"), 195 ("\r\n", "To Windows Line Endings (CRLF)", "Windows")): 196 action = self.line_endings_menu.addAction(text) 197 action.setCheckable(True) 198 action.setStatusTip(f"Save every line of the file in the editor with {tip}'s line ending") 199 action.triggered.connect(lambda _=False, n=newline: self.convert_line_endings(n)) 200 self.line_ending_actions[newline] = action 201 self.line_endings_menu.aboutToShow.connect(self._check_line_endings) 202 203 def go_to_file(self): 204 """Edit → Go To → Go to File… (⌘P): the files the Project files tree 205 lists (its Ignored checkbox respected), found by typing.""" 206 if self.manifest_path is None: 207 self.statusBar().showMessage("Go to File: open a project first", 4000) 208 return 209 tree = self.project_tree 210 if self._quick_open is None: 211 self._quick_open = QuickOpen(self, lambda path: self.open_file(path, force_editor=True), 212 tree.icon_for) 213 if self._command_palette is not None and self._command_palette.isVisible(): 214 self._command_palette.close() # (one palette at a time) 215 open_paths = [str(e.path) for e in self.editors() if not e.untitled] 216 self._quick_open.set_icon(self.go_to_file_action.icon()) # (in the theme's colors now) 217 self._quick_open.open(tree.all_files(), open_paths, tree.include_ignored.isChecked()) 218 219 def show_punch_card(self, editor: EditorTab): 220 """The hidden Punch Card command: the line at the cursor as an IBM card.""" 221 lines = editor.editor.toPlainText().split("\n") 222 name = "a new file" if editor.untitled else editor.path.name 223 run_dialog(PunchCardDialog(lines, editor.editor.textCursor().blockNumber(), name, self)) 224 225 def align_cursors(self, editor: EditorTab): 226 """Edit → Lines → Align Cursors (see CodeEditor.align_cursors).""" 227 if not editor.editor.align_cursors(): 228 self.statusBar().showMessage( 229 "Align Cursors: needs two or more cursors — " 230 + ("⌥-click to add one, ⌘⇧-click for one on each line" if sys.platform == "darwin" 231 else "Alt-click to add one, Ctrl+Shift-click for one on each line"), 6000) 232 233 def trim_trailing_whitespace(self, editor: EditorTab): 234 trimmed = editor.trim_trailing_whitespace() 235 lines = f"{trimmed} line{'s' * (trimmed != 1)}" if trimmed else "no lines" 236 self.statusBar().showMessage(f"{editor.path.name}: trailing whitespace trimmed from {lines}", 4000) 237 238 def set_trim_on_save(self, on: bool): 239 EditorTab.trim_on_save = on 240 self.settings.setValue("trim_on_save", on) 241 242 def _graph_in_use(self) -> bool: 243 """Find goes to the graph's search box: the graph panel has the focus, 244 or it's shown and no file is open.""" 245 dock = self.docks["graph"] 246 focus = QApplication.focusWidget() 247 if focus is not None and dock.isAncestorOf(focus): 248 return True 249 return self.current_editor() is None and self.panel_in_front(self._content(dock)) 250 251 def _find(self): 252 if self._graph_in_use(): 253 self.show_panel(self._content(self.docks["graph"])) 254 self.graph_view.focus_search() 255 else: 256 self._with_editor(lambda e: e.find_bar.open_bar()) 257 258 def _update_editor_actions(self, *_): 259 has_editor = self.current_editor() is not None 260 for action in (self.save_action, self.save_as_action, self.close_tab_action, 261 self.find_action, self.replace_action, self.find_next_action, 262 self.find_previous_action, self.goto_action, self.next_missed_action, 263 self.previous_missed_action): 264 action.setEnabled(has_editor) 265 graph = getattr(self, "docks", {}).get("graph") # (the panels exist) 266 self.find_action.setEnabled(has_editor or (graph is not None and self.panel_in_front(self._content(graph)))) 267 editor = self.current_editor() 268 self.format_action.setEnabled(editor is not None and formatter.can_format(editor.path) 269 and formatter.executable() is not None) 270 for action in (self.fix_safe_action, self.fix_all_action): 271 action.setEnabled(editor is not None and editor.can_lint()) 272 self.line_endings_menu.setEnabled(editor is not None) 273 self.comment_action.setEnabled(editor is not None and bool(editor.editor.comment_prefix)) 274 for submenu in (self.lines_menu, self.case_menu): 275 submenu.setEnabled(editor is not None) 276 self.trim_action.setEnabled(editor is not None) 277 self.punch_card_action.setEnabled(editor is not None) 278 for action in (self.definition_action, self.implementation_action, self.references_action, 279 self.rename_action, self.complete_action): 280 action.setEnabled(editor is not None and editor.lsp is not None) 281 self.symbol_action.setEnabled(self.lsp_client.is_running()) 282 self.back_action.setEnabled(bool(self._back)) 283 self.forward_action.setEnabled(bool(self._forward)) 284 self._update_review_actions() 285 286 def _rewrite_text(self, editor: EditorTab, doing: str, rewrite, done): 287 """Run ``rewrite(text) -> (new text or None, error)`` (a program: it can 288 take seconds on a big file) on a pool thread, then ``done(text, new, 289 error)`` in the GUI thread, if the file is still open and unchanged 290 (otherwise the result would undo what was typed meanwhile).""" 291 if editor.rewriting: 292 self.statusBar().showMessage(f"{editor.path.name}: still {doing}…", 3000) 293 return 294 editor.rewriting = True 295 text = editor.editor.toPlainText() 296 self.statusBar().showMessage(f"{doing.capitalize()} {editor.path.name}…") 297 298 def finished(result): 299 editor.rewriting = False 300 if editor not in self.editors(): 301 return # (closed meanwhile) 302 if editor.editor.toPlainText() != text: 303 self.statusBar().showMessage(f"{editor.path.name} changed while {doing}: nothing was changed " 304 "(try again)", 6000) 305 return 306 done(text, *result) 307 308 call_in_background(lambda: rewrite(text), finished, self) 309 310 def convert_line_endings(self, newline: str): 311 """Edit → Convert Line Endings: the whole file, saved with these from now on.""" 312 editor = self.current_editor() 313 if editor is None: 314 return 315 kind = "Unix (LF)" if newline == "\n" else "Windows (CRLF)" 316 if editor.convert_line_endings(newline): 317 self.statusBar().showMessage(f"{editor.path.name}: {kind} line endings (not saved yet)", 5000) 318 else: 319 self.statusBar().showMessage(f"{editor.path.name} already has {kind} line endings", 4000) 320 self._check_line_endings() 321 322 def _check_line_endings(self): 323 """Tick the current file's line ending in the menu.""" 324 editor = self.current_editor() 325 for newline, action in self.line_ending_actions.items(): 326 action.setChecked(editor is not None and editor.newline == newline and not editor.mixed_newlines) 327 328 def fix_lint_issues(self, editor: EditorTab, unsafe: bool): 329 """Edit → Fix Issues (fortitude) → Fix All … Issues: fortitude --fix on the editor's text (one 330 Undo step), run in the background.""" 331 if not editor.can_lint(): 332 return 333 expected = sum(1 for i in editor.editor.lint_issues if i.fixable and (unsafe or i.fix_safe)) 334 path = editor.path 335 336 def done(text, fixed, error): 337 if fixed is None: 338 QMessageBox.warning(self, "fortitude", f"{path.name} could not be fixed:\n\n{error}") 339 elif fixed == text: 340 kind = "fixable" if unsafe else "safely fixable" 341 self.statusBar().showMessage(f"{path.name}: no {kind} issues", 4000) 342 else: 343 editor.replace_text(fixed) 344 count = f"{expected} " if expected else "" 345 self.statusBar().showMessage(f"Applied {count}fortitude fix{'es' * (expected != 1)} to {path.name} " 346 "(Undo to revert; not saved yet)", 6000) 347 348 self._rewrite_text(editor, "fixing", lambda text: lint.fix_text(text, path, unsafe=unsafe), done) 349 350 def format_file(self, editor: EditorTab): 351 """Format the editor's (Fortran) text with fprettify, in the background.""" 352 if not formatter.can_format(editor.path): 353 return 354 path = editor.path 355 356 def done(text, formatted, error): 357 if formatted is None: 358 QMessageBox.warning(self, "fprettify failed", f"{path.name} could not be formatted:\n\n{error}") 359 elif formatted == text: 360 self.statusBar().showMessage(f"{path.name} is already formatted", 4000) 361 else: 362 editor.replace_text(formatted) 363 self.statusBar().showMessage(f"Formatted {path.name} with fprettify " 364 "(Undo to revert; not saved yet)", 6000) 365 366 self._rewrite_text(editor, "formatting", lambda text: formatter.format_text(text, path), done)
The Edit menu and the commands that rewrite a file (a part of vibefpmgui.main_window.MainWindow).
def
go_to_file(self):
203 def go_to_file(self): 204 """Edit → Go To → Go to File… (⌘P): the files the Project files tree 205 lists (its Ignored checkbox respected), found by typing.""" 206 if self.manifest_path is None: 207 self.statusBar().showMessage("Go to File: open a project first", 4000) 208 return 209 tree = self.project_tree 210 if self._quick_open is None: 211 self._quick_open = QuickOpen(self, lambda path: self.open_file(path, force_editor=True), 212 tree.icon_for) 213 if self._command_palette is not None and self._command_palette.isVisible(): 214 self._command_palette.close() # (one palette at a time) 215 open_paths = [str(e.path) for e in self.editors() if not e.untitled] 216 self._quick_open.set_icon(self.go_to_file_action.icon()) # (in the theme's colors now) 217 self._quick_open.open(tree.all_files(), open_paths, tree.include_ignored.isChecked())
Edit → Go To → Go to File… (⌘P): the files the Project files tree lists (its Ignored checkbox respected), found by typing.
219 def show_punch_card(self, editor: EditorTab): 220 """The hidden Punch Card command: the line at the cursor as an IBM card.""" 221 lines = editor.editor.toPlainText().split("\n") 222 name = "a new file" if editor.untitled else editor.path.name 223 run_dialog(PunchCardDialog(lines, editor.editor.textCursor().blockNumber(), name, self))
The hidden Punch Card command: the line at the cursor as an IBM card.
225 def align_cursors(self, editor: EditorTab): 226 """Edit → Lines → Align Cursors (see CodeEditor.align_cursors).""" 227 if not editor.editor.align_cursors(): 228 self.statusBar().showMessage( 229 "Align Cursors: needs two or more cursors — " 230 + ("⌥-click to add one, ⌘⇧-click for one on each line" if sys.platform == "darwin" 231 else "Alt-click to add one, Ctrl+Shift-click for one on each line"), 6000)
Edit → Lines → Align Cursors (see CodeEditor.align_cursors).
def
convert_line_endings(self, newline: str):
310 def convert_line_endings(self, newline: str): 311 """Edit → Convert Line Endings: the whole file, saved with these from now on.""" 312 editor = self.current_editor() 313 if editor is None: 314 return 315 kind = "Unix (LF)" if newline == "\n" else "Windows (CRLF)" 316 if editor.convert_line_endings(newline): 317 self.statusBar().showMessage(f"{editor.path.name}: {kind} line endings (not saved yet)", 5000) 318 else: 319 self.statusBar().showMessage(f"{editor.path.name} already has {kind} line endings", 4000) 320 self._check_line_endings()
Edit → Convert Line Endings: the whole file, saved with these from now on.
328 def fix_lint_issues(self, editor: EditorTab, unsafe: bool): 329 """Edit → Fix Issues (fortitude) → Fix All … Issues: fortitude --fix on the editor's text (one 330 Undo step), run in the background.""" 331 if not editor.can_lint(): 332 return 333 expected = sum(1 for i in editor.editor.lint_issues if i.fixable and (unsafe or i.fix_safe)) 334 path = editor.path 335 336 def done(text, fixed, error): 337 if fixed is None: 338 QMessageBox.warning(self, "fortitude", f"{path.name} could not be fixed:\n\n{error}") 339 elif fixed == text: 340 kind = "fixable" if unsafe else "safely fixable" 341 self.statusBar().showMessage(f"{path.name}: no {kind} issues", 4000) 342 else: 343 editor.replace_text(fixed) 344 count = f"{expected} " if expected else "" 345 self.statusBar().showMessage(f"Applied {count}fortitude fix{'es' * (expected != 1)} to {path.name} " 346 "(Undo to revert; not saved yet)", 6000) 347 348 self._rewrite_text(editor, "fixing", lambda text: lint.fix_text(text, path, unsafe=unsafe), done)
Edit → Fix Issues (fortitude) → Fix All … Issues: fortitude --fix on the editor's text (one Undo step), run in the background.
350 def format_file(self, editor: EditorTab): 351 """Format the editor's (Fortran) text with fprettify, in the background.""" 352 if not formatter.can_format(editor.path): 353 return 354 path = editor.path 355 356 def done(text, formatted, error): 357 if formatted is None: 358 QMessageBox.warning(self, "fprettify failed", f"{path.name} could not be formatted:\n\n{error}") 359 elif formatted == text: 360 self.statusBar().showMessage(f"{path.name} is already formatted", 4000) 361 else: 362 editor.replace_text(formatted) 363 self.statusBar().showMessage(f"Formatted {path.name} with fprettify " 364 "(Undo to revert; not saved yet)", 6000) 365 366 self._rewrite_text(editor, "formatting", lambda text: formatter.format_text(text, path), done)
Format the editor's (Fortran) text with fprettify, in the background.