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

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: 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()) 

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(): 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) 

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: 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() 

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(): 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 

334 

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) 

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): 351 ↛ 352line 351 didn't jump to line 352 because the condition on line 351 was never true

352 return 

353 path = editor.path 

354 

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) 

364 

365 self._rewrite_text(editor, "formatting", lambda text: formatter.format_text(text, path), done)