Coverage for vibefpmgui/window/editing.py: 90%
255 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-11-01 20:51 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-11-01 20:51 +0000
1"""The Edit menu, and the commands that rewrite a file (format, fix, line endings)."""
3from __future__ import annotations
5import sys
7from qtpy.QtGui import QAction, QKeySequence
8from qtpy.QtWidgets import QApplication, QMessageBox
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
18class EditingMixin:
19 """The Edit menu and the commands that rewrite a file (a part of `vibefpmgui.main_window.MainWindow`)."""
21 def _build_edit_menu(self):
22 menu = self.menuBar().addMenu("&Edit")
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
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()
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
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)
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: 205 ↛ 206line 205 didn't jump to line 206 because the condition on line 205 was never true
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: 209 ↛ 212line 209 didn't jump to line 212 because the condition on line 209 was always true
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())
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))
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)
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)
237 def set_trim_on_save(self, on: bool):
238 EditorTab.trim_on_save = on
239 self.settings.setValue("trim_on_save", on)
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))
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())
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()
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}…")
297 def finished(result):
298 editor.rewriting = False
299 if editor not in self.editors(): 299 ↛ 300line 299 didn't jump to line 300 because the condition on line 299 was never true
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)
307 call_in_background(lambda: rewrite(text), finished, self)
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: 312 ↛ 313line 312 didn't jump to line 313 because the condition on line 312 was never true
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()
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)
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(): 330 ↛ 331line 330 didn't jump to line 331 because the condition on line 330 was never true
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
335 def done(text, fixed, error):
336 if fixed is None: 336 ↛ 337line 336 didn't jump to line 337 because the condition on line 336 was never true
337 QMessageBox.warning(self, "fortitude", f"{path.name} could not be fixed:\n\n{error}")
338 elif fixed == text: 338 ↛ 339line 338 didn't jump to line 339 because the condition on line 338 was never true
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)
347 self._rewrite_text(editor, "fixing", lambda text: lint.fix_text(text, path, unsafe=unsafe), done)
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): 351 ↛ 352line 351 didn't jump to line 352 because the condition on line 351 was never true
352 return
353 path = editor.path
355 def done(text, formatted, error):
356 if formatted is None: 356 ↛ 357line 356 didn't jump to line 357 because the condition on line 356 was never true
357 QMessageBox.warning(self, "fprettify failed", f"{path.name} could not be formatted:\n\n{error}")
358 elif formatted == text: 358 ↛ 359line 358 didn't jump to line 359 because the condition on line 358 was never true
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)
365 self._rewrite_text(editor, "formatting", lambda text: formatter.format_text(text, path), done)