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()
class ScrollingTabBar(PySide6.QtWidgets.QTabBar):
 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).

def changed(unknown):

The type of the None singleton.

def empty_menu_requested(unknown):

The type of the None singleton.

def set_tab_name(self, index: int, name: str, italic: bool = False):
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).

def tabInserted(self, index):
61    def tabInserted(self, index):
62        super().tabInserted(index)
63        self.changed.emit()

tabInserted(self, index: int, /) -> None

def tabRemoved(self, index):
65    def tabRemoved(self, index):
66        super().tabRemoved(index)
67        self.changed.emit()

tabRemoved(self, index: int, /) -> None

def tab_name(self, index: int) -> str:
69    def tab_name(self, index: int) -> str:
70        return self.italic_name(index) or self.tabText(index)
def italic_name(self, index: int) -> str | None:
72    def italic_name(self, index: int) -> str | None:
73        name = self.tabData(index)
74        return name if isinstance(name, str) else None
NAME_GAP = 6
def tabSizeHint(self, index):
 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

def paintEvent(self, event):
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

def contextMenuEvent(self, event):
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

def wheelEvent(self, event):
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

staticMetaObject = PySide6.QtCore.QMetaObject("ScrollingTabBar" inherits "QTabBar": Methods: #39 type=Signal, signature=changed() #40 type=Signal, signature=empty_menu_requested(QPoint), parameters=QPoint )
class EditorTabsMixin:
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).

def set_tab_rows(self, wrap: bool, save: bool = True):
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.

def editors(self) -> list[vibefpmgui.editor.EditorTab]:
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)]
def current_editor(self) -> vibefpmgui.editor.EditorTab | None:
240    def current_editor(self) -> EditorTab | None:
241        widget = self.tabs.currentWidget()
242        return widget if isinstance(widget, EditorTab) else None
def browse_file(self):
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)
def preview_file(self, path: str):
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.

def open_file( self, path: str, diagnostics: list | None = None, force_editor: bool = False, preview: bool = False):
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.

def new_file(self):
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.

def close_all_editor_tabs(self):
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).

def rename_file(self, path: str):
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).

def delete_file(self, path: str):
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.

def close_all_editors(self) -> bool:
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.

def close_editor(self, index: int):
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()
def navigate_to( self, path: str, line: int, column: int, record: bool = True, preview: bool = False):
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()

Open a file at a line and column (Go to Definition / Symbol); Go Back returns to where this was called from. preview: in the preview tab, if it isn't open already (see preview_file).