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.

def show_punch_card(self, editor: vibefpmgui.editor.EditorTab):
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.

def align_cursors(self, editor: vibefpmgui.editor.EditorTab):
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 trim_trailing_whitespace(self, editor: vibefpmgui.editor.EditorTab):
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)
def set_trim_on_save(self, on: bool):
238    def set_trim_on_save(self, on: bool):
239        EditorTab.trim_on_save = on
240        self.settings.setValue("trim_on_save", on)
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.

def fix_lint_issues(self, editor: vibefpmgui.editor.EditorTab, unsafe: bool):
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.

def format_file(self, editor: vibefpmgui.editor.EditorTab):
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.