Search code examples
pythonpyside6

How do I control the tab that is deleted?


I am almost done implementing browser-like tab functionality in my Pyside6 app. I want to be able to load different pages based on the button the user presses on the new tab page. The only thing I do not quite have working is when someone closes a different tab than the one they are currently on. For instance, sometimes closing a different tab closes two tabs (the selected one) and the current one.

I know this has to do with my close_tab function and the default tabCloseRequested, but I am stumped as how to change it. A print statement in the close_tab shows it is only printed once so I think Pyside6 default closing behavior is conflicting with mine.

Can I get a look over to see what I am doing wrong?

My code:

from PySide6 import QtWidgets
from PySide6 import QtCore
from PySide6.QtCore import Signal, QSize
from PySide6.QtWidgets import QLabel, QWidget, QHBoxLayout, QVBoxLayout


class ShrinkTabBar(QtWidgets.QTabBar):
    _widthHint = -1
    _initialized = False
    _recursiveCheck = False

    addClicked = Signal()

    def __init__(self, parent):
        super(ShrinkTabBar, self).__init__(parent)
        self.setElideMode(QtCore.Qt.TextElideMode.ElideRight)
        self.setExpanding(False)
        self.setTabsClosable(True)
        self.addButton = QtWidgets.QToolButton(self.parent(), text='+')
        self.addButton.clicked.connect(self.addClicked)
        self._recursiveTimer = QtCore.QTimer(singleShot=True, timeout=self._unsetRecursiveCheck, interval=0)
        self._tabHint = QSize(0, 0)
        self._minimumHint = QSize(0, 0)

    def _unsetRecursiveCheck(self):
        self._recursiveCheck = False


    def _computeHints(self):
        if not self.count() or self._recursiveCheck:
            return
        self._recursiveCheck = True

        opt = QtWidgets.QStyleOptionTab()
        self.initStyleOption(opt, 0)
        width = self.style().pixelMetric(QtWidgets.QStyle.PixelMetric.PM_TabBarTabHSpace, opt, self)
        iconWidth = self.iconSize().width() + 4
        self._minimumWidth = width + iconWidth

        # default text widths are arbitrary
        fm = self.fontMetrics()
        self._minimumCloseWidth = self._minimumWidth + fm.horizontalAdvance('x' * 4) + iconWidth
        self._defaultWidth = width + fm.horizontalAdvance('x' * 17)
        self._defaultHeight = super().tabSizeHint(0).height()
        self._minimumHint = QtCore.QSize(self._minimumWidth, self._defaultHeight)
        self._defaultHint = self._tabHint = QtCore.QSize(self._defaultWidth, self._defaultHeight)

        self._initialized = True
        self._recursiveTimer.start()

    def _updateSize(self):
        if not self.count():
            return
        frameWidth = self.style().pixelMetric(
            QtWidgets.QStyle.PixelMetric.PM_DefaultFrameWidth, None, self.parent())
        buttonWidth = self.addButton.sizeHint().width()
        self._widthHint = (self.parent().width() - frameWidth - buttonWidth) // self.count()
        self._tabHint = QtCore.QSize(min(self._widthHint, self._defaultWidth), self._defaultHeight)
        # dirty trick to ensure that the layout is updated
        if not self._recursiveCheck:
            self._recursiveCheck = True
            self.setIconSize(self.iconSize())
            self._recursiveTimer.start()

    def minimumTabSizeHint(self, index):
        if not self._initialized:
            self._computeHints()
        return self._minimumHint

    def tabSizeHint(self, index):
        if not self._initialized:
            self._computeHints()
        return self._tabHint

    def tabLayoutChange(self):
        if self.count() and not self._recursiveCheck:
            self._updateSize()
#            self._closeIconTimer.start()

    def tabRemoved(self, index):
        if not self.count():
            self.addButton.setGeometry(1, 2,
                self.addButton.sizeHint().width(), self.height() - 4)

    def changeEvent(self, event):
        super().changeEvent(event)
        if event.type() in (event.StyleChange, event.FontChange):
            self._updateSize()

    def resizeEvent(self, event):
        if not self.count():
            super().resizeEvent(event)
            return
        self._recursiveCheck = True
        super().resizeEvent(event)
        height = self.sizeHint().height()
        if height < 0:
            # a tab bar without tabs returns an invalid size
            height = self.addButton.height()
        self.addButton.setGeometry(self.geometry().right() + 1, 2,
            self.addButton.sizeHint().width(), height - 4)
#        self._closeIconTimer.start()
        self._recursiveTimer.start()


class ShrinkTabWidget(QtWidgets.QTabWidget):
    addClicked = Signal()

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self._tabBar = ShrinkTabBar(self)
        self.setTabBar(self._tabBar)
        self._tabBar.tabCloseRequested.connect(self.close_tab)
        self._tabBar.addClicked.connect(self.get_page_choices)

    def get_page_choices(self):
        selection_page = SelectionPage()
        index = self.addTab(selection_page, 'New Tab')
        self._tabBar.setCurrentIndex(index)   # Set as current tab

    def resizeEvent(self, event):
        self._tabBar._updateSize()
        super().resizeEvent(event)

    def close_tab(self, index):
        self.widget(index).deleteLater()
        self._tabBar.removeTab(index)


class TabContainer(QWidget):
    def __init__(self):
        super(TabContainer, self).__init__()
        self.setGeometry(150, 150, 650, 350)
        self.tabwidget = ShrinkTabWidget(self)
        self.top_vbox = QtWidgets.QHBoxLayout()
        self.top_vbox.addWidget(self.tabwidget)
        self.setLayout(self.top_vbox)
        self.show()


class SelectionPage(QWidget):
    def __init__(self):
        super(SelectionPage, self).__init__()
        self.vbox = QtWidgets.QVBoxLayout()
        self.portfolio_btn = QtWidgets.QPushButton("Portfolio")
        self.portfolio_btn.clicked.connect(self.get_portfolio_page)
        self.vbox.addWidget(self.portfolio_btn)
        self.setLayout(self.vbox)

    def get_portfolio_page(self):
        children = []
        for i in range(self.vbox.count()):
            child = self.vbox.itemAt(i).widget()
            if child:
                children.append(child)
        for child in children:
            child.deleteLater()
        self.vbox.deleteLater()

        portfolio_page = PortfolioPage()
        self.vbox.addWidget(portfolio_page)


class PortfolioPage(QWidget):
    def __init__(self):
        super(PortfolioPage, self).__init__()
        vbox = QVBoxLayout()
        label = QLabel('Test')
        vbox.addWidget(label)
        self.setLayout(vbox)



import sys

if __name__ == "__main__":
    app = QtWidgets.QApplication(sys.argv)
    tab_container = TabContainer()
    tab_container.show()
    sys.exit(app.exec())

Solution

  • You are not understanding the structure of QTabWidget.

    As the documentation clearly explains:

    A tab widget provides a tab bar (see QTabBar) and a "page area" that is used to display pages related to each tab.

    Strictly speaking, here is the object structure:

    • QTabWidget: a mere container that provides some helper functions and signals that allow to interact with its contents (placed in an internal layout), which are:
      • QTabBar, a widget showing tabs, having has absolutely no knowledge about the contents of those "tabs";
      • an internal QStackedWidget, that actually shows pages, with each page being the contents of every "tab", which are:
        • a certain (and possibly empty) amount of Qt widgets that are individually shown whenever a specified tab is selected;

    QTabWidget allows to set a custom QTabBar widget (in order to provide custom look or behavior), but interaction with the QTabWidget pages should always be done from the QTabWidget, not the tab bar, and that is for obvious reasons related to the object structure (see the hierarchy of the list above).

    If you want to remove a tab, you should do it from the tab widget, not the tab bar, and that's because, as said above, the tab bar knows absolutely nothing about the contents of the tab widget.

    So, theoretically, close_tab should be like this:

        def close_tab(self, index):
            widget = self.widget(index)
            self.removeTab(index) # <- NOT self._tabBar.removeTab(index)!!!
            widget.deleteLater()
    

    Note that the reference creation and, most importantly, the order of removal calls is quite different and must not be underestimated; while, in theory, deleteLater() only destroys a QObject as soon as control returns to the event loop, that is not guaranteed, since an object can be marked for removal and not result in the correct list of existing "pages"; and if you just inverted the order of removeTab() and self.widget(index).deleteLater() you'd obviously get the wrong widget, or even an AttributeError if you removed the last tab, since self.widget() would eventually return None. I repeat: the order of execution and function calls is very important.

    In reality, though, QTabWidget is able to automatically remove tabs for "pages" that have been destroyed, so, unless you need to keep the object alive for further usage (eg. temporarily hide it, or show it on a separate window), you do not normally need to actually remove the tab, because you can just delete the widget and the parenthood system of Qt will do the rest on its own:

        def close_tab(self, index):
            self.widget(index).deleteLater()
            # that's it!
    

    Why? How?

    Well, QTabWidget assumes that each one of its "tabs" is related to an actual widget (a page): there are no "ghost tabs", if a page is removed, the assumption is that the related tab should be removed as well: there's no point in keeping that tab.

    This happens since the internal QStackedWidget mentioned above has a widgetRemoved() signal that is emitted whenever any of its pages are removed (no matter if they have been destroyed or just hidden/reparented).

    QTabWidget just automatically connects that signal in its constructor (when it creates the QStackedWidget) to an internal function that finally removes the tab, which is done by also calling removeTab() on its tab bar.