vibefpmgui.window.editor_tabs
The editor's tabs: opening, previewing, closing, renaming and deleting files.
Also moving around them: Go to a location, Go Back and Go Forward.
1"""The editor's tabs: opening, previewing, closing, renaming and deleting files. 2 3Also moving around them: Go to a location, Go Back and Go Forward. 4""" 5 6from __future__ import annotations 7 8import os 9from pathlib import Path 10 11from qtpy.QtCore import QFile, QPoint, QRect, QSize, Qt, QUrl, Signal 12from qtpy.QtGui import ( 13 QAction, 14 QActionGroup, 15 QColor, 16 QDesktopServices, 17 QFont, 18 QFontMetrics, 19 QKeySequence, 20) 21from qtpy.QtWidgets import ( 22 QFileDialog, 23 QHBoxLayout, 24 QInputDialog, 25 QLabel, 26 QMenu, 27 QMessageBox, 28 QStyle, 29 QStyleOptionTab, 30 QStylePainter, 31 QTabBar, 32 QTabWidget, 33 QToolButton, 34 QVBoxLayout, 35 QWidget, 36) 37 38from ..editor import EditorTab 39from ..icons import make_icon 40from ..wrapping_tabs import WrappingTabBar 41 42 43class ScrollingTabBar(QTabBar): 44 """A tab bar where the mouse wheel or a trackpad scrolls the tabs 45 sideways when they don't all fit, rather than switching tabs; a preview 46 tab's name is in italics (see set_italic).""" 47 48 changed = Signal() # a tab added, removed or renamed (for the tabs on several rows: WrappingTabBar) 49 empty_menu_requested = Signal(QPoint) # a right-click beside the tabs (on none): where, on the screen 50 51 def set_tab_name(self, index: int, name: str, italic: bool = False): 52 """A tab's name, in italics or not. Qt has no font per tab: an italic 53 one's own text is left empty, and its name drawn here (paintEvent).""" 54 self.setTabData(index, name if italic else None) 55 self.setTabText(index, "" if italic else name) 56 self.updateGeometry() 57 self.update() 58 self.changed.emit() 59 60 def tabInserted(self, index): 61 super().tabInserted(index) 62 self.changed.emit() 63 64 def tabRemoved(self, index): 65 super().tabRemoved(index) 66 self.changed.emit() 67 68 def tab_name(self, index: int) -> str: 69 return self.italic_name(index) or self.tabText(index) 70 71 def italic_name(self, index: int) -> str | None: 72 name = self.tabData(index) 73 return name if isinstance(name, str) else None 74 75 def _italic_font(self) -> QFont: 76 font = QFont(self.font()) 77 font.setItalic(True) 78 return font 79 80 NAME_GAP = 6 # between the name and the icon or close button beside it 81 82 def tabSizeHint(self, index): 83 size = super().tabSizeHint(index) 84 name = self.italic_name(index) 85 if name is not None: # (room for the name it doesn't have as text) 86 size.setWidth(size.width() + QFontMetrics(self._italic_font()).horizontalAdvance(name) + 4) 87 # No more room around the name than NAME_GAP on each side: some styles 88 # (macOS's) leave its buttons at the tab's edges and center the name in 89 # a much wider space. As wide as the style leaves for the name, less 90 # what's spare (a style that leaves none, as Fusion, is unchanged). 91 option = QStyleOptionTab() 92 self.initStyleOption(option, index) 93 option.rect = QRect(QPoint(0, 0), size) 94 room = self.style().subElementRect(QStyle.SE_TabBarTabText, option, self).width() 95 font = self._italic_font() if name is not None else self.font() 96 spare = room - QFontMetrics(font).horizontalAdvance(self.tab_name(index)) - 2 * self.NAME_GAP 97 if spare > 0: 98 size.setWidth(size.width() - spare) 99 return size 100 101 def paintEvent(self, event): 102 super().paintEvent(event) 103 names = [(i, self.italic_name(i)) for i in range(self.count())] 104 if not any(name for _i, name in names): 105 return 106 painter = QStylePainter(self) 107 painter.setFont(self._italic_font()) 108 for index, name in names: 109 if name is not None: 110 option = QStyleOptionTab() 111 self.initStyleOption(option, index) 112 option.text = name 113 painter.drawControl(QStyle.CE_TabBarTabLabel, option) 114 115 def contextMenuEvent(self, event): 116 if self.tabAt(event.pos()) < 0: 117 self.empty_menu_requested.emit(event.globalPos()) 118 event.accept() 119 else: 120 super().contextMenuEvent(event) 121 122 def wheelEvent(self, event): 123 arrows = [b for b in self.findChildren(QToolButton) if b.isVisible() and b.arrowType() in 124 (Qt.LeftArrow, Qt.RightArrow)] 125 if not arrows: 126 super().wheelEvent(event) 127 return 128 delta = event.angleDelta() 129 step = delta.x() if abs(delta.x()) > abs(delta.y()) else delta.y() 130 self._wheel_rest = getattr(self, "_wheel_rest", 0) + step 131 while abs(self._wheel_rest) >= 120: # one notch (or that much trackpad travel) per tab 132 back = self._wheel_rest > 0 133 self._wheel_rest -= 120 if back else -120 134 for button in arrows: 135 if button.arrowType() == (Qt.LeftArrow if back else Qt.RightArrow) and button.isEnabled(): 136 button.click() 137 event.accept() 138 139 140class EditorTabsMixin: 141 """The editor's tabs (a part of `vibefpmgui.main_window.MainWindow`).""" 142 143 def _build_editor_tabs(self): 144 """The editor's tabs (the middle of the window).""" 145 self.tabs = QTabWidget() 146 self.tabs.setTabBar(ScrollingTabBar()) # (before any tabs are added) 147 self.tabs.setDocumentMode(True) 148 # With many files open, scroll the tab bar (arrows, wheel or trackpad) 149 # rather than squeezing every tab; macOS's style turns the arrows off 150 # and shortens the names by default. 151 self.tabs.setUsesScrollButtons(True) 152 self.tabs.setElideMode(Qt.ElideNone) 153 self.tabs.tabBar().setExpanding(False) 154 # A list of every open tab, at the right end of the tab bar. 155 tab_list = self.tab_list = QMenu(self.tabs) 156 tab_list.aboutToShow.connect(lambda: self._fill_tab_list(tab_list)) 157 self.tab_list_button = self._tab_list_button() 158 self.tabs.setCornerWidget(self.tab_list_button, Qt.TopRightCorner) 159 # The middle of the window: files open in the editor, each in a closable tab. 160 self.tabs.setTabsClosable(True) 161 self.tabs.setMovable(True) 162 self.tabs.tabCloseRequested.connect(self.close_editor) 163 self.tabs.tabBarDoubleClicked.connect(lambda i: self._make_permanent(self.tabs.widget(i))) 164 self.tabs.currentChanged.connect(self._update_editor_actions) 165 self.tabs.currentChanged.connect(self._reveal_current_file) 166 self.tabs.currentChanged.connect(self._update_central) 167 self.tabs.currentChanged.connect(self._update_problems) 168 # The tabs on several rows instead of one that scrolls (View → Editor Tabs): 169 # above the tabs, which then hide their own bar. 170 self.tab_rows = WrappingTabBar(self.tabs) 171 self.tab_rows.close_requested.connect(self.close_editor) 172 self.tabs.tabBar().changed.connect(self.tab_rows.refresh) 173 # Right-click beside the tabs: the Editor Tabs choices (as in the View menu). 174 self.tabs.tabBar().empty_menu_requested.connect(self._tabs_menu) 175 self.tab_rows.empty_menu_requested.connect(self._tabs_menu) 176 # (The tab list, with Close All Editor Tabs, at the rows' end: as at the one row's.) 177 self.tab_rows_list_button = self._tab_list_button() 178 rows = QHBoxLayout() 179 rows.setContentsMargins(0, 0, 0, 0) 180 rows.setSpacing(0) 181 rows.addWidget(self.tab_rows, 1) 182 rows.addWidget(self.tab_rows_list_button, 0, Qt.AlignTop) 183 self.editor_area = QWidget() 184 area = QVBoxLayout(self.editor_area) 185 area.setContentsMargins(0, 0, 0, 0) 186 area.setSpacing(0) 187 area.addLayout(rows) 188 area.addWidget(self.tabs, 1) 189 menu = self.tab_rows_menu = self.view_menu.addMenu("Editor Tabs") # (kept: PySide would let it go) 190 group = QActionGroup(menu) 191 self.tab_rows_actions: dict[bool, QAction] = {} 192 for wrap, text, tip in ((False, "One Row (Scroll)", ("The editor's tabs side by side, scrolling when they " 193 "don't all fit (the wheel or a trackpad scrolls them)")), 194 (True, "Several Rows (Wrap)", ("The editor's tabs on as many rows as they need, " 195 "all in view"))): 196 action = menu.addAction(text) 197 action.setCheckable(True) 198 action.setStatusTip(tip) 199 action.triggered.connect(lambda _=False, w=wrap: self.set_tab_rows(w)) 200 group.addAction(action) 201 self.tab_rows_actions[wrap] = action 202 self.set_tab_rows(self.settings.value("tab_rows_wrap", False, type=bool), save=False) 203 204 def _tab_list_button(self) -> QToolButton: 205 """A button for the list of every open tab (the tab bar's corner, or the rows' end).""" 206 button = QToolButton() 207 button.setArrowType(Qt.DownArrow) 208 button.setAutoRaise(True) 209 button.setToolTip("All open tabs") 210 button.setPopupMode(QToolButton.InstantPopup) 211 # Just the one arrow (a button with a menu also gets a menu indicator). 212 button.setStyleSheet("QToolButton::menu-indicator { image: none; width: 0px; }") 213 button.setMenu(self.tab_list) 214 return button 215 216 def _tabs_menu(self, where: QPoint): 217 """The menu for a right-click beside the editor's tabs: View → Editor Tabs.""" 218 menu = self.tabs_menu = QMenu(self) # (kept while it's open) 219 for action in self.tab_rows_menu.actions(): # (the same actions: checked alike) 220 menu.addAction(action) 221 menu.aboutToHide.connect(menu.deleteLater) 222 menu.popup(where) 223 224 def set_tab_rows(self, wrap: bool, save: bool = True): 225 """View → Editor Tabs: one row that scrolls, or several rows.""" 226 self.tab_rows_actions[wrap].setChecked(True) 227 if save: 228 self.settings.setValue("tab_rows_wrap", wrap) 229 self.tabs.tabBar().setVisible(not wrap) 230 self.tab_list_button.setVisible(not wrap) # (the rows have their own, at their end) 231 self.tab_rows.setVisible(wrap) 232 self.tab_rows_list_button.setVisible(wrap) 233 self.tab_rows.refresh() 234 235 def editors(self) -> list[EditorTab]: 236 return [w for i in range(self.tabs.count()) 237 if isinstance(w := self.tabs.widget(i), EditorTab)] 238 239 def current_editor(self) -> EditorTab | None: 240 widget = self.tabs.currentWidget() 241 return widget if isinstance(widget, EditorTab) else None 242 243 def _with_editor(self, action): 244 editor = self.current_editor() 245 if editor is not None: 246 action(editor) 247 248 def browse_file(self): 249 start = str(self.manifest_path.parent) if self.manifest_path else str(Path.home()) 250 filename, _ = QFileDialog.getOpenFileName(self, "Open file in editor", start) 251 if filename: 252 self.open_file(filename, force_editor=True) 253 254 def preview_file(self, path: str): 255 """Project files → one click: show the file in the preview tab (its 256 name in italics), in place of the one previewed before. Editing it, 257 or double-clicking the file or the tab, keeps it open.""" 258 file = Path(path).resolve() 259 if self.use_editor_action.isChecked() and file.is_file() and EditorTab.is_probably_text(file): 260 self.open_file(str(file), force_editor=True, preview=True) 261 262 def _preview_editor(self) -> EditorTab | None: 263 return self._preview if self._preview in self.editors() else None 264 265 def _keep_open(self, path: str): 266 """A file opened as a preview, kept open (a result double-clicked).""" 267 editor = next((e for e in self.editors() if e.path == Path(path).resolve()), None) 268 if editor is not None: 269 self._make_permanent(editor) 270 271 def _make_permanent(self, editor): 272 """A preview tab stays open from now on (its name no longer in italics).""" 273 if editor is not None and editor is self._preview: 274 self._preview = None 275 self._update_editor_title(editor) 276 277 def open_file(self, path: str, diagnostics: list | None = None, force_editor: bool = False, 278 preview: bool = False): 279 """Open a file in the built-in editor (or the default application), 280 marking any compiler diagnostics and jumping to the first one. 281 ``preview``: in the preview tab (see preview_file), and the keyboard 282 stays where it is.""" 283 file = Path(path).resolve() 284 editor = next((e for e in self.editors() if e.path == file), None) # (open already: there, even unsaved) 285 use_editor = force_editor or self.use_editor_action.isChecked() 286 if editor is None and (not use_editor or not file.is_file() or not EditorTab.is_probably_text(file)): 287 QDesktopServices.openUrl(QUrl.fromLocalFile(str(file))) 288 return 289 if editor is not None and not preview: 290 self._make_permanent(editor) # (opened for real: a preview no longer) 291 if editor is None: 292 try: 293 editor = EditorTab(file) 294 except OSError as exc: 295 QMessageBox.warning(self, "Can't open file", f"{file}:\n{exc}") 296 return 297 # A new preview takes the old one's place (unless that one has changes: then it stays). 298 replaced = self._preview_editor() if preview else None 299 index = self.tabs.indexOf(replaced) if replaced is not None and not replaced.is_dirty() else -1 300 if preview: 301 self._preview = editor 302 self._add_editor(editor, index) 303 if index >= 0: 304 self._close_tab(replaced) 305 self.tabs.setCurrentWidget(editor) 306 if diagnostics: 307 editor.show_diagnostics(diagnostics) 308 editor.editor.go_to_line(diagnostics[0].line) 309 elif not preview: 310 editor.editor.setFocus() 311 312 def new_file(self): 313 """File → New File: an empty tab for a file not saved yet ("Untitled-1"…, 314 in the project's folder when it's saved, unless another is chosen). 315 Plain text until Save As gives it a name, and with it a file type.""" 316 folder = self.manifest_path.parent if self.manifest_path else Path.home() 317 taken = {e.path.name for e in self.editors()} 318 number = 1 319 while f"Untitled-{number}" in taken or (folder / f"Untitled-{number}").exists(): 320 number += 1 321 editor = EditorTab(folder / f"Untitled-{number}", untitled=True) 322 self._add_editor(editor) 323 self.tabs.setCurrentWidget(editor) 324 editor.editor.setFocus() 325 326 def _file_created(self, path: str): 327 """Save As made a new file: in Project files now.""" 328 self.project_tree.refresh() 329 self.project_tree.reveal(path) 330 331 def _add_editor(self, editor: EditorTab, index: int = -1): 332 """A new tab for ``editor`` (at ``index``, or the end), connected to the window.""" 333 editor.set_font(self._mono_font(editor=True)) 334 self._show_coverage(editor) 335 editor.dirty_changed.connect(lambda _dirty, e=editor: self._update_editor_title(e)) 336 editor.path_changed.connect(lambda _p, e=editor: self._update_editor_title(e)) 337 editor.path_changed.connect(self._update_editor_actions) 338 editor.saved.connect(self._editor_saved) 339 editor.created.connect(self._file_created) 340 editor.navigate_requested.connect(self.navigate_to) 341 editor.references_found.connect(self._show_references) 342 editor.rename_ready.connect(self._preview_rename) 343 editor.editor.word_added.connect(self.add_spelling_word) 344 editor.explain_requested.connect(lambda problem, e=editor: self.explain_problem(e, problem)) 345 editor.editor.edit_selection_requested.connect(lambda e=editor: self.edit_with_ai(e)) 346 editor.problems_changed.connect(lambda e=editor: self._problems_timer.start() if e is self.current_editor() 347 else None) 348 editor.editor.textChanged.connect( # (a new file not saved yet: not in Find in Files) 349 lambda e=editor: None if e.untitled else self.search_panel.file_changed(e.path)) 350 editor.attach_lsp(self.lsp_client) 351 self._debug_editor_added(editor) # (its breakpoints: see window.debugging) 352 # (Changing a preview's text keeps it open.) 353 editor.dirty_changed.connect(lambda dirty, e=editor: dirty and self._make_permanent(e)) 354 if index >= 0: 355 self.tabs.insertTab(index, editor, editor.path.name) 356 else: 357 self.tabs.addTab(editor, editor.path.name) 358 self._add_close_button(editor) 359 self._update_editor_title(editor) 360 self._save_session() 361 362 def _close_tab(self, editor: EditorTab): 363 """Close an editor tab without asking (see close_editor for that).""" 364 self._debug_editor_closing(editor) # (its breakpoints kept) 365 editor.close_down() 366 self.tabs.removeTab(self.tabs.indexOf(editor)) 367 editor.deleteLater() 368 if editor is self._preview: 369 self._preview = None 370 371 def _add_close_button(self, editor: EditorTab): 372 """The file's icon on the left of an editor tab's name, and our own × 373 on the right (macOS's dark-mode close button is hard, or impossible, 374 to see). Each side gets one small widget: macOS's style places the 375 name as if the widget beside it were the usual size, so a wider one 376 (or a tab icon) covers the start of the name.""" 377 button = QToolButton() 378 button.setAutoRaise(True) 379 button.setFixedSize(18, 18) 380 button.setIconSize(QSize(12, 12)) 381 button.setCursor(Qt.ArrowCursor) 382 keys = self.close_tab_action.shortcut().toString(QKeySequence.NativeText) 383 button.setToolTip(f"Close ({keys})" if keys else "Close") 384 button.setStyleSheet("QToolButton { border: none; border-radius: 4px; padding: 0; }" 385 "QToolButton:hover { background: rgba(128, 128, 128, 80); }") 386 button.clicked.connect(lambda _=False, e=editor: self.close_editor(self.tabs.indexOf(e))) 387 editor.close_button = button 388 self._style_close_button(button) 389 icon = QLabel() 390 icon.setFixedSize(16, 16) 391 editor.tab_icon = icon 392 self._set_tab_icon(editor) 393 bar = self.tabs.tabBar() 394 index = self.tabs.indexOf(editor) 395 bar.setTabButton(index, QTabBar.LeftSide, icon) 396 bar.setTabButton(index, QTabBar.RightSide, button) 397 398 def _set_tab_icon(self, editor: EditorTab): 399 """The file's icon, as in Project files (it changes with Save As).""" 400 label = getattr(editor, "tab_icon", None) 401 if label is not None: 402 label.setPixmap(self.project_tree.icon_for(editor.path).pixmap(16, 16)) 403 404 @staticmethod 405 def _style_close_button(button: QToolButton): 406 # A mid gray: seen on the dark tabs and on the light selected one 407 # (macOS's dark mode draws the selected tab light). 408 button.setIcon(make_icon("close", QColor(140, 140, 140))) 409 410 def _fill_tab_list(self, menu: QMenu): 411 """The tab-list menu: every open tab (the current one ticked).""" 412 menu.clear() 413 current = self.tabs.currentIndex() 414 for index in range(self.tabs.count()): # (every tab is a file open in the editor) 415 icon = self.project_tree.icon_for(self.tabs.widget(index).path) 416 action = menu.addAction(icon, self.tabs.tabBar().tab_name(index)) 417 action.setCheckable(True) 418 action.setChecked(index == current) 419 action.setToolTip(self.tabs.tabToolTip(index)) 420 action.triggered.connect(lambda _checked=False, i=index: self.tabs.setCurrentIndex(i)) 421 menu.addSeparator() 422 close_all = menu.addAction("Close All Editor Tabs") 423 close_all.setToolTip("Close every file open in the editor (unsaved changes are asked about)") 424 close_all.setEnabled(bool(self.editors())) 425 close_all.triggered.connect(self.close_all_editor_tabs) 426 menu.setToolTipsVisible(True) 427 428 def close_all_editor_tabs(self): 429 """The tab list's Close All Editor Tabs (Cancel on an unsaved file 430 keeps them all open).""" 431 if self.close_all_editors(): 432 self._save_session() # so they don't come back next time 433 self._update_editor_actions() 434 435 def _update_editor_title(self, editor: EditorTab): 436 index = self.tabs.indexOf(editor) 437 if index >= 0: 438 self.tabs.tabBar().set_tab_name(index, ("• " if editor.is_dirty() else "") + editor.path.name, 439 italic=editor is self._preview) 440 self.tabs.setTabToolTip(index, editor.display_path()) 441 self._set_tab_icon(editor) 442 self._update_editor_actions() 443 444 def _editor_saved(self, path: str): 445 self.statusBar().showMessage(f"Saved {path}", 4000) 446 self.refresh_git() 447 self._complexity_file_saved(path) 448 if self.manifest_path and Path(path) == self.manifest_path: 449 self.reload() # graph, features and profiles may have changed 450 elif self.config_path is not None and Path(path).resolve() == self.config_path.resolve(): 451 self.reload_configurations() # (the build configurations, edited as text) 452 453 def rename_file(self, path: str): 454 """Project files → Rename…: rename a file on disk; a tab it's open in 455 follows it (unsaved changes included).""" 456 old = Path(path).resolve() 457 name, ok = QInputDialog.getText(self, "Rename", f"New name for {old.name}:", text=old.name) 458 name = name.strip() if ok else "" 459 if not name or name == old.name: 460 return 461 if "/" in name or "\\" in name or name in (".", ".."): 462 QMessageBox.warning(self, "Rename", f"“{name}” isn't a file name (it can't contain / or \\).") 463 return 464 new = old.with_name(name) 465 if new.exists() and not os.path.samefile(new, old): # (another file; a change of case is fine) 466 QMessageBox.warning(self, "Rename", f"There's already a file named {name} in {old.parent.name}.") 467 return 468 try: 469 old.rename(new) 470 except OSError as exc: 471 QMessageBox.warning(self, "Rename", f"Couldn't rename {old.name}:\n{exc}") 472 return 473 editor = self._open_editor_for(old) 474 if editor is not None: 475 editor.renamed(new) 476 self._save_session() 477 self._lsp_diagnostics_by_file.pop(old, None) 478 self.search_panel.file_changed(old) 479 self.search_panel.file_changed(new) 480 self.project_tree.refresh() 481 self.project_tree.reveal(str(new)) 482 self.statusBar().showMessage(f"Renamed {old.name} to {name}", 4000) 483 484 def delete_file(self, path: str): 485 """Project files → Move to Trash…: after asking (and, for a file open 486 with unsaved changes, whether to save them first), move a file to the 487 trash and close its tab.""" 488 file = Path(path).resolve() 489 editor = self._open_editor_for(file) 490 if editor is not None and editor.is_dirty(): 491 self.tabs.setCurrentWidget(editor) 492 answer = QMessageBox.question( 493 self, "Move to Trash", 494 f"{file.name} has unsaved changes. Save them before moving it to the trash?", 495 QMessageBox.Save | QMessageBox.Discard | QMessageBox.Cancel, QMessageBox.Cancel) 496 if answer == QMessageBox.Cancel or (answer == QMessageBox.Save and not editor.save()): 497 return 498 else: 499 answer = QMessageBox.question(self, "Move to Trash", f"Move {file.name} to the trash?", 500 QMessageBox.Yes | QMessageBox.Cancel, QMessageBox.Cancel) 501 if answer != QMessageBox.Yes: 502 return 503 moved = QFile.moveToTrash(str(file)) 504 moved = moved[0] if isinstance(moved, tuple) else moved # (PySide: (ok, path in the trash)) 505 if not moved: 506 QMessageBox.warning(self, "Move to Trash", f"Couldn't move {file.name} to the trash.") 507 return 508 if editor is not None: 509 self._close_tab(editor) 510 self._save_session() 511 self._lsp_diagnostics_by_file.pop(file, None) 512 self.search_panel.file_changed(file) 513 self.project_tree.refresh() 514 self.statusBar().showMessage(f"Moved {file.name} to the trash", 4000) 515 516 def close_all_editors(self) -> bool: 517 """Close every editor tab. Unsaved files are asked about first; if 518 any answer is Cancel, nothing is closed and False is returned.""" 519 editors = self.editors() 520 for editor in editors: 521 if editor.is_dirty(): 522 self.tabs.setCurrentWidget(editor) 523 if not editor.maybe_save(): 524 return False 525 for editor in editors: 526 self._close_tab(editor) 527 return True 528 529 def close_editor(self, index: int): 530 widget = self.tabs.widget(index) 531 if not isinstance(widget, EditorTab): 532 return 533 self.tabs.setCurrentIndex(index) 534 if widget.maybe_save(): 535 self._close_tab(widget) 536 self._save_session() 537 538 def _location(self) -> tuple[str, int, int] | None: 539 editor = self.current_editor() 540 if editor is None: 541 return None 542 cursor = editor.editor.textCursor() 543 return str(editor.path), cursor.blockNumber() + 1, cursor.positionInBlock() 544 545 def navigate_to(self, path: str, line: int, column: int, record: bool = True, preview: bool = False): 546 """Open a file at a line and column (Go to Definition / Symbol); 547 Go Back returns to where this was called from. ``preview``: in the 548 preview tab, if it isn't open already (see preview_file).""" 549 here = self._location() 550 if record and here is not None and here != (path, line, column): 551 self._back.append(here) 552 del self._back[:-100] 553 self._forward.clear() 554 self.open_file(path, force_editor=True, preview=preview) 555 editor = self.current_editor() 556 if editor is not None and editor.path == Path(path).resolve(): 557 editor.editor.go_to_position(line, column) 558 editor.editor.setFocus() 559 self._update_editor_actions() 560 561 def _go_history(self, back: bool): 562 source, target = (self._back, self._forward) if back else (self._forward, self._back) 563 while source: 564 path, line, column = source.pop() 565 if Path(path).is_file(): 566 here = self._location() 567 if here is not None: 568 target.append(here) 569 self.navigate_to(path, line, column, record=False) 570 return 571 self._update_editor_actions()
44class ScrollingTabBar(QTabBar): 45 """A tab bar where the mouse wheel or a trackpad scrolls the tabs 46 sideways when they don't all fit, rather than switching tabs; a preview 47 tab's name is in italics (see set_italic).""" 48 49 changed = Signal() # a tab added, removed or renamed (for the tabs on several rows: WrappingTabBar) 50 empty_menu_requested = Signal(QPoint) # a right-click beside the tabs (on none): where, on the screen 51 52 def set_tab_name(self, index: int, name: str, italic: bool = False): 53 """A tab's name, in italics or not. Qt has no font per tab: an italic 54 one's own text is left empty, and its name drawn here (paintEvent).""" 55 self.setTabData(index, name if italic else None) 56 self.setTabText(index, "" if italic else name) 57 self.updateGeometry() 58 self.update() 59 self.changed.emit() 60 61 def tabInserted(self, index): 62 super().tabInserted(index) 63 self.changed.emit() 64 65 def tabRemoved(self, index): 66 super().tabRemoved(index) 67 self.changed.emit() 68 69 def tab_name(self, index: int) -> str: 70 return self.italic_name(index) or self.tabText(index) 71 72 def italic_name(self, index: int) -> str | None: 73 name = self.tabData(index) 74 return name if isinstance(name, str) else None 75 76 def _italic_font(self) -> QFont: 77 font = QFont(self.font()) 78 font.setItalic(True) 79 return font 80 81 NAME_GAP = 6 # between the name and the icon or close button beside it 82 83 def tabSizeHint(self, index): 84 size = super().tabSizeHint(index) 85 name = self.italic_name(index) 86 if name is not None: # (room for the name it doesn't have as text) 87 size.setWidth(size.width() + QFontMetrics(self._italic_font()).horizontalAdvance(name) + 4) 88 # No more room around the name than NAME_GAP on each side: some styles 89 # (macOS's) leave its buttons at the tab's edges and center the name in 90 # a much wider space. As wide as the style leaves for the name, less 91 # what's spare (a style that leaves none, as Fusion, is unchanged). 92 option = QStyleOptionTab() 93 self.initStyleOption(option, index) 94 option.rect = QRect(QPoint(0, 0), size) 95 room = self.style().subElementRect(QStyle.SE_TabBarTabText, option, self).width() 96 font = self._italic_font() if name is not None else self.font() 97 spare = room - QFontMetrics(font).horizontalAdvance(self.tab_name(index)) - 2 * self.NAME_GAP 98 if spare > 0: 99 size.setWidth(size.width() - spare) 100 return size 101 102 def paintEvent(self, event): 103 super().paintEvent(event) 104 names = [(i, self.italic_name(i)) for i in range(self.count())] 105 if not any(name for _i, name in names): 106 return 107 painter = QStylePainter(self) 108 painter.setFont(self._italic_font()) 109 for index, name in names: 110 if name is not None: 111 option = QStyleOptionTab() 112 self.initStyleOption(option, index) 113 option.text = name 114 painter.drawControl(QStyle.CE_TabBarTabLabel, option) 115 116 def contextMenuEvent(self, event): 117 if self.tabAt(event.pos()) < 0: 118 self.empty_menu_requested.emit(event.globalPos()) 119 event.accept() 120 else: 121 super().contextMenuEvent(event) 122 123 def wheelEvent(self, event): 124 arrows = [b for b in self.findChildren(QToolButton) if b.isVisible() and b.arrowType() in 125 (Qt.LeftArrow, Qt.RightArrow)] 126 if not arrows: 127 super().wheelEvent(event) 128 return 129 delta = event.angleDelta() 130 step = delta.x() if abs(delta.x()) > abs(delta.y()) else delta.y() 131 self._wheel_rest = getattr(self, "_wheel_rest", 0) + step 132 while abs(self._wheel_rest) >= 120: # one notch (or that much trackpad travel) per tab 133 back = self._wheel_rest > 0 134 self._wheel_rest -= 120 if back else -120 135 for button in arrows: 136 if button.arrowType() == (Qt.LeftArrow if back else Qt.RightArrow) and button.isEnabled(): 137 button.click() 138 event.accept()
A tab bar where the mouse wheel or a trackpad scrolls the tabs sideways when they don't all fit, rather than switching tabs; a preview tab's name is in italics (see set_italic).
52 def set_tab_name(self, index: int, name: str, italic: bool = False): 53 """A tab's name, in italics or not. Qt has no font per tab: an italic 54 one's own text is left empty, and its name drawn here (paintEvent).""" 55 self.setTabData(index, name if italic else None) 56 self.setTabText(index, "" if italic else name) 57 self.updateGeometry() 58 self.update() 59 self.changed.emit()
A tab's name, in italics or not. Qt has no font per tab: an italic one's own text is left empty, and its name drawn here (paintEvent).
83 def tabSizeHint(self, index): 84 size = super().tabSizeHint(index) 85 name = self.italic_name(index) 86 if name is not None: # (room for the name it doesn't have as text) 87 size.setWidth(size.width() + QFontMetrics(self._italic_font()).horizontalAdvance(name) + 4) 88 # No more room around the name than NAME_GAP on each side: some styles 89 # (macOS's) leave its buttons at the tab's edges and center the name in 90 # a much wider space. As wide as the style leaves for the name, less 91 # what's spare (a style that leaves none, as Fusion, is unchanged). 92 option = QStyleOptionTab() 93 self.initStyleOption(option, index) 94 option.rect = QRect(QPoint(0, 0), size) 95 room = self.style().subElementRect(QStyle.SE_TabBarTabText, option, self).width() 96 font = self._italic_font() if name is not None else self.font() 97 spare = room - QFontMetrics(font).horizontalAdvance(self.tab_name(index)) - 2 * self.NAME_GAP 98 if spare > 0: 99 size.setWidth(size.width() - spare) 100 return size
tabSizeHint(self, index: int, /) -> PySide6.QtCore.QSize
102 def paintEvent(self, event): 103 super().paintEvent(event) 104 names = [(i, self.italic_name(i)) for i in range(self.count())] 105 if not any(name for _i, name in names): 106 return 107 painter = QStylePainter(self) 108 painter.setFont(self._italic_font()) 109 for index, name in names: 110 if name is not None: 111 option = QStyleOptionTab() 112 self.initStyleOption(option, index) 113 option.text = name 114 painter.drawControl(QStyle.CE_TabBarTabLabel, option)
paintEvent(self, arg__1: PySide6.QtGui.QPaintEvent, /) -> None
116 def contextMenuEvent(self, event): 117 if self.tabAt(event.pos()) < 0: 118 self.empty_menu_requested.emit(event.globalPos()) 119 event.accept() 120 else: 121 super().contextMenuEvent(event)
contextMenuEvent(self, event: PySide6.QtGui.QContextMenuEvent, /) -> None
123 def wheelEvent(self, event): 124 arrows = [b for b in self.findChildren(QToolButton) if b.isVisible() and b.arrowType() in 125 (Qt.LeftArrow, Qt.RightArrow)] 126 if not arrows: 127 super().wheelEvent(event) 128 return 129 delta = event.angleDelta() 130 step = delta.x() if abs(delta.x()) > abs(delta.y()) else delta.y() 131 self._wheel_rest = getattr(self, "_wheel_rest", 0) + step 132 while abs(self._wheel_rest) >= 120: # one notch (or that much trackpad travel) per tab 133 back = self._wheel_rest > 0 134 self._wheel_rest -= 120 if back else -120 135 for button in arrows: 136 if button.arrowType() == (Qt.LeftArrow if back else Qt.RightArrow) and button.isEnabled(): 137 button.click() 138 event.accept()
wheelEvent(self, event: PySide6.QtGui.QWheelEvent, /) -> None
141class EditorTabsMixin: 142 """The editor's tabs (a part of `vibefpmgui.main_window.MainWindow`).""" 143 144 def _build_editor_tabs(self): 145 """The editor's tabs (the middle of the window).""" 146 self.tabs = QTabWidget() 147 self.tabs.setTabBar(ScrollingTabBar()) # (before any tabs are added) 148 self.tabs.setDocumentMode(True) 149 # With many files open, scroll the tab bar (arrows, wheel or trackpad) 150 # rather than squeezing every tab; macOS's style turns the arrows off 151 # and shortens the names by default. 152 self.tabs.setUsesScrollButtons(True) 153 self.tabs.setElideMode(Qt.ElideNone) 154 self.tabs.tabBar().setExpanding(False) 155 # A list of every open tab, at the right end of the tab bar. 156 tab_list = self.tab_list = QMenu(self.tabs) 157 tab_list.aboutToShow.connect(lambda: self._fill_tab_list(tab_list)) 158 self.tab_list_button = self._tab_list_button() 159 self.tabs.setCornerWidget(self.tab_list_button, Qt.TopRightCorner) 160 # The middle of the window: files open in the editor, each in a closable tab. 161 self.tabs.setTabsClosable(True) 162 self.tabs.setMovable(True) 163 self.tabs.tabCloseRequested.connect(self.close_editor) 164 self.tabs.tabBarDoubleClicked.connect(lambda i: self._make_permanent(self.tabs.widget(i))) 165 self.tabs.currentChanged.connect(self._update_editor_actions) 166 self.tabs.currentChanged.connect(self._reveal_current_file) 167 self.tabs.currentChanged.connect(self._update_central) 168 self.tabs.currentChanged.connect(self._update_problems) 169 # The tabs on several rows instead of one that scrolls (View → Editor Tabs): 170 # above the tabs, which then hide their own bar. 171 self.tab_rows = WrappingTabBar(self.tabs) 172 self.tab_rows.close_requested.connect(self.close_editor) 173 self.tabs.tabBar().changed.connect(self.tab_rows.refresh) 174 # Right-click beside the tabs: the Editor Tabs choices (as in the View menu). 175 self.tabs.tabBar().empty_menu_requested.connect(self._tabs_menu) 176 self.tab_rows.empty_menu_requested.connect(self._tabs_menu) 177 # (The tab list, with Close All Editor Tabs, at the rows' end: as at the one row's.) 178 self.tab_rows_list_button = self._tab_list_button() 179 rows = QHBoxLayout() 180 rows.setContentsMargins(0, 0, 0, 0) 181 rows.setSpacing(0) 182 rows.addWidget(self.tab_rows, 1) 183 rows.addWidget(self.tab_rows_list_button, 0, Qt.AlignTop) 184 self.editor_area = QWidget() 185 area = QVBoxLayout(self.editor_area) 186 area.setContentsMargins(0, 0, 0, 0) 187 area.setSpacing(0) 188 area.addLayout(rows) 189 area.addWidget(self.tabs, 1) 190 menu = self.tab_rows_menu = self.view_menu.addMenu("Editor Tabs") # (kept: PySide would let it go) 191 group = QActionGroup(menu) 192 self.tab_rows_actions: dict[bool, QAction] = {} 193 for wrap, text, tip in ((False, "One Row (Scroll)", ("The editor's tabs side by side, scrolling when they " 194 "don't all fit (the wheel or a trackpad scrolls them)")), 195 (True, "Several Rows (Wrap)", ("The editor's tabs on as many rows as they need, " 196 "all in view"))): 197 action = menu.addAction(text) 198 action.setCheckable(True) 199 action.setStatusTip(tip) 200 action.triggered.connect(lambda _=False, w=wrap: self.set_tab_rows(w)) 201 group.addAction(action) 202 self.tab_rows_actions[wrap] = action 203 self.set_tab_rows(self.settings.value("tab_rows_wrap", False, type=bool), save=False) 204 205 def _tab_list_button(self) -> QToolButton: 206 """A button for the list of every open tab (the tab bar's corner, or the rows' end).""" 207 button = QToolButton() 208 button.setArrowType(Qt.DownArrow) 209 button.setAutoRaise(True) 210 button.setToolTip("All open tabs") 211 button.setPopupMode(QToolButton.InstantPopup) 212 # Just the one arrow (a button with a menu also gets a menu indicator). 213 button.setStyleSheet("QToolButton::menu-indicator { image: none; width: 0px; }") 214 button.setMenu(self.tab_list) 215 return button 216 217 def _tabs_menu(self, where: QPoint): 218 """The menu for a right-click beside the editor's tabs: View → Editor Tabs.""" 219 menu = self.tabs_menu = QMenu(self) # (kept while it's open) 220 for action in self.tab_rows_menu.actions(): # (the same actions: checked alike) 221 menu.addAction(action) 222 menu.aboutToHide.connect(menu.deleteLater) 223 menu.popup(where) 224 225 def set_tab_rows(self, wrap: bool, save: bool = True): 226 """View → Editor Tabs: one row that scrolls, or several rows.""" 227 self.tab_rows_actions[wrap].setChecked(True) 228 if save: 229 self.settings.setValue("tab_rows_wrap", wrap) 230 self.tabs.tabBar().setVisible(not wrap) 231 self.tab_list_button.setVisible(not wrap) # (the rows have their own, at their end) 232 self.tab_rows.setVisible(wrap) 233 self.tab_rows_list_button.setVisible(wrap) 234 self.tab_rows.refresh() 235 236 def editors(self) -> list[EditorTab]: 237 return [w for i in range(self.tabs.count()) 238 if isinstance(w := self.tabs.widget(i), EditorTab)] 239 240 def current_editor(self) -> EditorTab | None: 241 widget = self.tabs.currentWidget() 242 return widget if isinstance(widget, EditorTab) else None 243 244 def _with_editor(self, action): 245 editor = self.current_editor() 246 if editor is not None: 247 action(editor) 248 249 def browse_file(self): 250 start = str(self.manifest_path.parent) if self.manifest_path else str(Path.home()) 251 filename, _ = QFileDialog.getOpenFileName(self, "Open file in editor", start) 252 if filename: 253 self.open_file(filename, force_editor=True) 254 255 def preview_file(self, path: str): 256 """Project files → one click: show the file in the preview tab (its 257 name in italics), in place of the one previewed before. Editing it, 258 or double-clicking the file or the tab, keeps it open.""" 259 file = Path(path).resolve() 260 if self.use_editor_action.isChecked() and file.is_file() and EditorTab.is_probably_text(file): 261 self.open_file(str(file), force_editor=True, preview=True) 262 263 def _preview_editor(self) -> EditorTab | None: 264 return self._preview if self._preview in self.editors() else None 265 266 def _keep_open(self, path: str): 267 """A file opened as a preview, kept open (a result double-clicked).""" 268 editor = next((e for e in self.editors() if e.path == Path(path).resolve()), None) 269 if editor is not None: 270 self._make_permanent(editor) 271 272 def _make_permanent(self, editor): 273 """A preview tab stays open from now on (its name no longer in italics).""" 274 if editor is not None and editor is self._preview: 275 self._preview = None 276 self._update_editor_title(editor) 277 278 def open_file(self, path: str, diagnostics: list | None = None, force_editor: bool = False, 279 preview: bool = False): 280 """Open a file in the built-in editor (or the default application), 281 marking any compiler diagnostics and jumping to the first one. 282 ``preview``: in the preview tab (see preview_file), and the keyboard 283 stays where it is.""" 284 file = Path(path).resolve() 285 editor = next((e for e in self.editors() if e.path == file), None) # (open already: there, even unsaved) 286 use_editor = force_editor or self.use_editor_action.isChecked() 287 if editor is None and (not use_editor or not file.is_file() or not EditorTab.is_probably_text(file)): 288 QDesktopServices.openUrl(QUrl.fromLocalFile(str(file))) 289 return 290 if editor is not None and not preview: 291 self._make_permanent(editor) # (opened for real: a preview no longer) 292 if editor is None: 293 try: 294 editor = EditorTab(file) 295 except OSError as exc: 296 QMessageBox.warning(self, "Can't open file", f"{file}:\n{exc}") 297 return 298 # A new preview takes the old one's place (unless that one has changes: then it stays). 299 replaced = self._preview_editor() if preview else None 300 index = self.tabs.indexOf(replaced) if replaced is not None and not replaced.is_dirty() else -1 301 if preview: 302 self._preview = editor 303 self._add_editor(editor, index) 304 if index >= 0: 305 self._close_tab(replaced) 306 self.tabs.setCurrentWidget(editor) 307 if diagnostics: 308 editor.show_diagnostics(diagnostics) 309 editor.editor.go_to_line(diagnostics[0].line) 310 elif not preview: 311 editor.editor.setFocus() 312 313 def new_file(self): 314 """File → New File: an empty tab for a file not saved yet ("Untitled-1"…, 315 in the project's folder when it's saved, unless another is chosen). 316 Plain text until Save As gives it a name, and with it a file type.""" 317 folder = self.manifest_path.parent if self.manifest_path else Path.home() 318 taken = {e.path.name for e in self.editors()} 319 number = 1 320 while f"Untitled-{number}" in taken or (folder / f"Untitled-{number}").exists(): 321 number += 1 322 editor = EditorTab(folder / f"Untitled-{number}", untitled=True) 323 self._add_editor(editor) 324 self.tabs.setCurrentWidget(editor) 325 editor.editor.setFocus() 326 327 def _file_created(self, path: str): 328 """Save As made a new file: in Project files now.""" 329 self.project_tree.refresh() 330 self.project_tree.reveal(path) 331 332 def _add_editor(self, editor: EditorTab, index: int = -1): 333 """A new tab for ``editor`` (at ``index``, or the end), connected to the window.""" 334 editor.set_font(self._mono_font(editor=True)) 335 self._show_coverage(editor) 336 editor.dirty_changed.connect(lambda _dirty, e=editor: self._update_editor_title(e)) 337 editor.path_changed.connect(lambda _p, e=editor: self._update_editor_title(e)) 338 editor.path_changed.connect(self._update_editor_actions) 339 editor.saved.connect(self._editor_saved) 340 editor.created.connect(self._file_created) 341 editor.navigate_requested.connect(self.navigate_to) 342 editor.references_found.connect(self._show_references) 343 editor.rename_ready.connect(self._preview_rename) 344 editor.editor.word_added.connect(self.add_spelling_word) 345 editor.explain_requested.connect(lambda problem, e=editor: self.explain_problem(e, problem)) 346 editor.editor.edit_selection_requested.connect(lambda e=editor: self.edit_with_ai(e)) 347 editor.problems_changed.connect(lambda e=editor: self._problems_timer.start() if e is self.current_editor() 348 else None) 349 editor.editor.textChanged.connect( # (a new file not saved yet: not in Find in Files) 350 lambda e=editor: None if e.untitled else self.search_panel.file_changed(e.path)) 351 editor.attach_lsp(self.lsp_client) 352 self._debug_editor_added(editor) # (its breakpoints: see window.debugging) 353 # (Changing a preview's text keeps it open.) 354 editor.dirty_changed.connect(lambda dirty, e=editor: dirty and self._make_permanent(e)) 355 if index >= 0: 356 self.tabs.insertTab(index, editor, editor.path.name) 357 else: 358 self.tabs.addTab(editor, editor.path.name) 359 self._add_close_button(editor) 360 self._update_editor_title(editor) 361 self._save_session() 362 363 def _close_tab(self, editor: EditorTab): 364 """Close an editor tab without asking (see close_editor for that).""" 365 self._debug_editor_closing(editor) # (its breakpoints kept) 366 editor.close_down() 367 self.tabs.removeTab(self.tabs.indexOf(editor)) 368 editor.deleteLater() 369 if editor is self._preview: 370 self._preview = None 371 372 def _add_close_button(self, editor: EditorTab): 373 """The file's icon on the left of an editor tab's name, and our own × 374 on the right (macOS's dark-mode close button is hard, or impossible, 375 to see). Each side gets one small widget: macOS's style places the 376 name as if the widget beside it were the usual size, so a wider one 377 (or a tab icon) covers the start of the name.""" 378 button = QToolButton() 379 button.setAutoRaise(True) 380 button.setFixedSize(18, 18) 381 button.setIconSize(QSize(12, 12)) 382 button.setCursor(Qt.ArrowCursor) 383 keys = self.close_tab_action.shortcut().toString(QKeySequence.NativeText) 384 button.setToolTip(f"Close ({keys})" if keys else "Close") 385 button.setStyleSheet("QToolButton { border: none; border-radius: 4px; padding: 0; }" 386 "QToolButton:hover { background: rgba(128, 128, 128, 80); }") 387 button.clicked.connect(lambda _=False, e=editor: self.close_editor(self.tabs.indexOf(e))) 388 editor.close_button = button 389 self._style_close_button(button) 390 icon = QLabel() 391 icon.setFixedSize(16, 16) 392 editor.tab_icon = icon 393 self._set_tab_icon(editor) 394 bar = self.tabs.tabBar() 395 index = self.tabs.indexOf(editor) 396 bar.setTabButton(index, QTabBar.LeftSide, icon) 397 bar.setTabButton(index, QTabBar.RightSide, button) 398 399 def _set_tab_icon(self, editor: EditorTab): 400 """The file's icon, as in Project files (it changes with Save As).""" 401 label = getattr(editor, "tab_icon", None) 402 if label is not None: 403 label.setPixmap(self.project_tree.icon_for(editor.path).pixmap(16, 16)) 404 405 @staticmethod 406 def _style_close_button(button: QToolButton): 407 # A mid gray: seen on the dark tabs and on the light selected one 408 # (macOS's dark mode draws the selected tab light). 409 button.setIcon(make_icon("close", QColor(140, 140, 140))) 410 411 def _fill_tab_list(self, menu: QMenu): 412 """The tab-list menu: every open tab (the current one ticked).""" 413 menu.clear() 414 current = self.tabs.currentIndex() 415 for index in range(self.tabs.count()): # (every tab is a file open in the editor) 416 icon = self.project_tree.icon_for(self.tabs.widget(index).path) 417 action = menu.addAction(icon, self.tabs.tabBar().tab_name(index)) 418 action.setCheckable(True) 419 action.setChecked(index == current) 420 action.setToolTip(self.tabs.tabToolTip(index)) 421 action.triggered.connect(lambda _checked=False, i=index: self.tabs.setCurrentIndex(i)) 422 menu.addSeparator() 423 close_all = menu.addAction("Close All Editor Tabs") 424 close_all.setToolTip("Close every file open in the editor (unsaved changes are asked about)") 425 close_all.setEnabled(bool(self.editors())) 426 close_all.triggered.connect(self.close_all_editor_tabs) 427 menu.setToolTipsVisible(True) 428 429 def close_all_editor_tabs(self): 430 """The tab list's Close All Editor Tabs (Cancel on an unsaved file 431 keeps them all open).""" 432 if self.close_all_editors(): 433 self._save_session() # so they don't come back next time 434 self._update_editor_actions() 435 436 def _update_editor_title(self, editor: EditorTab): 437 index = self.tabs.indexOf(editor) 438 if index >= 0: 439 self.tabs.tabBar().set_tab_name(index, ("• " if editor.is_dirty() else "") + editor.path.name, 440 italic=editor is self._preview) 441 self.tabs.setTabToolTip(index, editor.display_path()) 442 self._set_tab_icon(editor) 443 self._update_editor_actions() 444 445 def _editor_saved(self, path: str): 446 self.statusBar().showMessage(f"Saved {path}", 4000) 447 self.refresh_git() 448 self._complexity_file_saved(path) 449 if self.manifest_path and Path(path) == self.manifest_path: 450 self.reload() # graph, features and profiles may have changed 451 elif self.config_path is not None and Path(path).resolve() == self.config_path.resolve(): 452 self.reload_configurations() # (the build configurations, edited as text) 453 454 def rename_file(self, path: str): 455 """Project files → Rename…: rename a file on disk; a tab it's open in 456 follows it (unsaved changes included).""" 457 old = Path(path).resolve() 458 name, ok = QInputDialog.getText(self, "Rename", f"New name for {old.name}:", text=old.name) 459 name = name.strip() if ok else "" 460 if not name or name == old.name: 461 return 462 if "/" in name or "\\" in name or name in (".", ".."): 463 QMessageBox.warning(self, "Rename", f"“{name}” isn't a file name (it can't contain / or \\).") 464 return 465 new = old.with_name(name) 466 if new.exists() and not os.path.samefile(new, old): # (another file; a change of case is fine) 467 QMessageBox.warning(self, "Rename", f"There's already a file named {name} in {old.parent.name}.") 468 return 469 try: 470 old.rename(new) 471 except OSError as exc: 472 QMessageBox.warning(self, "Rename", f"Couldn't rename {old.name}:\n{exc}") 473 return 474 editor = self._open_editor_for(old) 475 if editor is not None: 476 editor.renamed(new) 477 self._save_session() 478 self._lsp_diagnostics_by_file.pop(old, None) 479 self.search_panel.file_changed(old) 480 self.search_panel.file_changed(new) 481 self.project_tree.refresh() 482 self.project_tree.reveal(str(new)) 483 self.statusBar().showMessage(f"Renamed {old.name} to {name}", 4000) 484 485 def delete_file(self, path: str): 486 """Project files → Move to Trash…: after asking (and, for a file open 487 with unsaved changes, whether to save them first), move a file to the 488 trash and close its tab.""" 489 file = Path(path).resolve() 490 editor = self._open_editor_for(file) 491 if editor is not None and editor.is_dirty(): 492 self.tabs.setCurrentWidget(editor) 493 answer = QMessageBox.question( 494 self, "Move to Trash", 495 f"{file.name} has unsaved changes. Save them before moving it to the trash?", 496 QMessageBox.Save | QMessageBox.Discard | QMessageBox.Cancel, QMessageBox.Cancel) 497 if answer == QMessageBox.Cancel or (answer == QMessageBox.Save and not editor.save()): 498 return 499 else: 500 answer = QMessageBox.question(self, "Move to Trash", f"Move {file.name} to the trash?", 501 QMessageBox.Yes | QMessageBox.Cancel, QMessageBox.Cancel) 502 if answer != QMessageBox.Yes: 503 return 504 moved = QFile.moveToTrash(str(file)) 505 moved = moved[0] if isinstance(moved, tuple) else moved # (PySide: (ok, path in the trash)) 506 if not moved: 507 QMessageBox.warning(self, "Move to Trash", f"Couldn't move {file.name} to the trash.") 508 return 509 if editor is not None: 510 self._close_tab(editor) 511 self._save_session() 512 self._lsp_diagnostics_by_file.pop(file, None) 513 self.search_panel.file_changed(file) 514 self.project_tree.refresh() 515 self.statusBar().showMessage(f"Moved {file.name} to the trash", 4000) 516 517 def close_all_editors(self) -> bool: 518 """Close every editor tab. Unsaved files are asked about first; if 519 any answer is Cancel, nothing is closed and False is returned.""" 520 editors = self.editors() 521 for editor in editors: 522 if editor.is_dirty(): 523 self.tabs.setCurrentWidget(editor) 524 if not editor.maybe_save(): 525 return False 526 for editor in editors: 527 self._close_tab(editor) 528 return True 529 530 def close_editor(self, index: int): 531 widget = self.tabs.widget(index) 532 if not isinstance(widget, EditorTab): 533 return 534 self.tabs.setCurrentIndex(index) 535 if widget.maybe_save(): 536 self._close_tab(widget) 537 self._save_session() 538 539 def _location(self) -> tuple[str, int, int] | None: 540 editor = self.current_editor() 541 if editor is None: 542 return None 543 cursor = editor.editor.textCursor() 544 return str(editor.path), cursor.blockNumber() + 1, cursor.positionInBlock() 545 546 def navigate_to(self, path: str, line: int, column: int, record: bool = True, preview: bool = False): 547 """Open a file at a line and column (Go to Definition / Symbol); 548 Go Back returns to where this was called from. ``preview``: in the 549 preview tab, if it isn't open already (see preview_file).""" 550 here = self._location() 551 if record and here is not None and here != (path, line, column): 552 self._back.append(here) 553 del self._back[:-100] 554 self._forward.clear() 555 self.open_file(path, force_editor=True, preview=preview) 556 editor = self.current_editor() 557 if editor is not None and editor.path == Path(path).resolve(): 558 editor.editor.go_to_position(line, column) 559 editor.editor.setFocus() 560 self._update_editor_actions() 561 562 def _go_history(self, back: bool): 563 source, target = (self._back, self._forward) if back else (self._forward, self._back) 564 while source: 565 path, line, column = source.pop() 566 if Path(path).is_file(): 567 here = self._location() 568 if here is not None: 569 target.append(here) 570 self.navigate_to(path, line, column, record=False) 571 return 572 self._update_editor_actions()
The editor's tabs (a part of vibefpmgui.main_window.MainWindow).
225 def set_tab_rows(self, wrap: bool, save: bool = True): 226 """View → Editor Tabs: one row that scrolls, or several rows.""" 227 self.tab_rows_actions[wrap].setChecked(True) 228 if save: 229 self.settings.setValue("tab_rows_wrap", wrap) 230 self.tabs.tabBar().setVisible(not wrap) 231 self.tab_list_button.setVisible(not wrap) # (the rows have their own, at their end) 232 self.tab_rows.setVisible(wrap) 233 self.tab_rows_list_button.setVisible(wrap) 234 self.tab_rows.refresh()
View → Editor Tabs: one row that scrolls, or several rows.
255 def preview_file(self, path: str): 256 """Project files → one click: show the file in the preview tab (its 257 name in italics), in place of the one previewed before. Editing it, 258 or double-clicking the file or the tab, keeps it open.""" 259 file = Path(path).resolve() 260 if self.use_editor_action.isChecked() and file.is_file() and EditorTab.is_probably_text(file): 261 self.open_file(str(file), force_editor=True, preview=True)
Project files → one click: show the file in the preview tab (its name in italics), in place of the one previewed before. Editing it, or double-clicking the file or the tab, keeps it open.
278 def open_file(self, path: str, diagnostics: list | None = None, force_editor: bool = False, 279 preview: bool = False): 280 """Open a file in the built-in editor (or the default application), 281 marking any compiler diagnostics and jumping to the first one. 282 ``preview``: in the preview tab (see preview_file), and the keyboard 283 stays where it is.""" 284 file = Path(path).resolve() 285 editor = next((e for e in self.editors() if e.path == file), None) # (open already: there, even unsaved) 286 use_editor = force_editor or self.use_editor_action.isChecked() 287 if editor is None and (not use_editor or not file.is_file() or not EditorTab.is_probably_text(file)): 288 QDesktopServices.openUrl(QUrl.fromLocalFile(str(file))) 289 return 290 if editor is not None and not preview: 291 self._make_permanent(editor) # (opened for real: a preview no longer) 292 if editor is None: 293 try: 294 editor = EditorTab(file) 295 except OSError as exc: 296 QMessageBox.warning(self, "Can't open file", f"{file}:\n{exc}") 297 return 298 # A new preview takes the old one's place (unless that one has changes: then it stays). 299 replaced = self._preview_editor() if preview else None 300 index = self.tabs.indexOf(replaced) if replaced is not None and not replaced.is_dirty() else -1 301 if preview: 302 self._preview = editor 303 self._add_editor(editor, index) 304 if index >= 0: 305 self._close_tab(replaced) 306 self.tabs.setCurrentWidget(editor) 307 if diagnostics: 308 editor.show_diagnostics(diagnostics) 309 editor.editor.go_to_line(diagnostics[0].line) 310 elif not preview: 311 editor.editor.setFocus()
Open a file in the built-in editor (or the default application),
marking any compiler diagnostics and jumping to the first one.
preview: in the preview tab (see preview_file), and the keyboard
stays where it is.
313 def new_file(self): 314 """File → New File: an empty tab for a file not saved yet ("Untitled-1"…, 315 in the project's folder when it's saved, unless another is chosen). 316 Plain text until Save As gives it a name, and with it a file type.""" 317 folder = self.manifest_path.parent if self.manifest_path else Path.home() 318 taken = {e.path.name for e in self.editors()} 319 number = 1 320 while f"Untitled-{number}" in taken or (folder / f"Untitled-{number}").exists(): 321 number += 1 322 editor = EditorTab(folder / f"Untitled-{number}", untitled=True) 323 self._add_editor(editor) 324 self.tabs.setCurrentWidget(editor) 325 editor.editor.setFocus()
File → New File: an empty tab for a file not saved yet ("Untitled-1"…, in the project's folder when it's saved, unless another is chosen). Plain text until Save As gives it a name, and with it a file type.
429 def close_all_editor_tabs(self): 430 """The tab list's Close All Editor Tabs (Cancel on an unsaved file 431 keeps them all open).""" 432 if self.close_all_editors(): 433 self._save_session() # so they don't come back next time 434 self._update_editor_actions()
The tab list's Close All Editor Tabs (Cancel on an unsaved file keeps them all open).
454 def rename_file(self, path: str): 455 """Project files → Rename…: rename a file on disk; a tab it's open in 456 follows it (unsaved changes included).""" 457 old = Path(path).resolve() 458 name, ok = QInputDialog.getText(self, "Rename", f"New name for {old.name}:", text=old.name) 459 name = name.strip() if ok else "" 460 if not name or name == old.name: 461 return 462 if "/" in name or "\\" in name or name in (".", ".."): 463 QMessageBox.warning(self, "Rename", f"“{name}” isn't a file name (it can't contain / or \\).") 464 return 465 new = old.with_name(name) 466 if new.exists() and not os.path.samefile(new, old): # (another file; a change of case is fine) 467 QMessageBox.warning(self, "Rename", f"There's already a file named {name} in {old.parent.name}.") 468 return 469 try: 470 old.rename(new) 471 except OSError as exc: 472 QMessageBox.warning(self, "Rename", f"Couldn't rename {old.name}:\n{exc}") 473 return 474 editor = self._open_editor_for(old) 475 if editor is not None: 476 editor.renamed(new) 477 self._save_session() 478 self._lsp_diagnostics_by_file.pop(old, None) 479 self.search_panel.file_changed(old) 480 self.search_panel.file_changed(new) 481 self.project_tree.refresh() 482 self.project_tree.reveal(str(new)) 483 self.statusBar().showMessage(f"Renamed {old.name} to {name}", 4000)
Project files → Rename…: rename a file on disk; a tab it's open in follows it (unsaved changes included).
485 def delete_file(self, path: str): 486 """Project files → Move to Trash…: after asking (and, for a file open 487 with unsaved changes, whether to save them first), move a file to the 488 trash and close its tab.""" 489 file = Path(path).resolve() 490 editor = self._open_editor_for(file) 491 if editor is not None and editor.is_dirty(): 492 self.tabs.setCurrentWidget(editor) 493 answer = QMessageBox.question( 494 self, "Move to Trash", 495 f"{file.name} has unsaved changes. Save them before moving it to the trash?", 496 QMessageBox.Save | QMessageBox.Discard | QMessageBox.Cancel, QMessageBox.Cancel) 497 if answer == QMessageBox.Cancel or (answer == QMessageBox.Save and not editor.save()): 498 return 499 else: 500 answer = QMessageBox.question(self, "Move to Trash", f"Move {file.name} to the trash?", 501 QMessageBox.Yes | QMessageBox.Cancel, QMessageBox.Cancel) 502 if answer != QMessageBox.Yes: 503 return 504 moved = QFile.moveToTrash(str(file)) 505 moved = moved[0] if isinstance(moved, tuple) else moved # (PySide: (ok, path in the trash)) 506 if not moved: 507 QMessageBox.warning(self, "Move to Trash", f"Couldn't move {file.name} to the trash.") 508 return 509 if editor is not None: 510 self._close_tab(editor) 511 self._save_session() 512 self._lsp_diagnostics_by_file.pop(file, None) 513 self.search_panel.file_changed(file) 514 self.project_tree.refresh() 515 self.statusBar().showMessage(f"Moved {file.name} to the trash", 4000)
Project files → Move to Trash…: after asking (and, for a file open with unsaved changes, whether to save them first), move a file to the trash and close its tab.
517 def close_all_editors(self) -> bool: 518 """Close every editor tab. Unsaved files are asked about first; if 519 any answer is Cancel, nothing is closed and False is returned.""" 520 editors = self.editors() 521 for editor in editors: 522 if editor.is_dirty(): 523 self.tabs.setCurrentWidget(editor) 524 if not editor.maybe_save(): 525 return False 526 for editor in editors: 527 self._close_tab(editor) 528 return True
Close every editor tab. Unsaved files are asked about first; if any answer is Cancel, nothing is closed and False is returned.