1

I'm trying to have a + button added to a QTabBar. There's a great solution from years ago, with a slight issue that it doesn't work with PySide2. The problem is caused by the tabs auto resizing to fill the sizeHint, which in this case isn't wanted as the extra space is needed. Is there a way I can disable this behaviour?

I've tried QTabBar.setExpanding(False), but according to this answer, the property is mostly ignored:

The bad news is that QTabWidget effectively ignores that property, because it always forces its tabs to be the minimum size (even if you set your own tab-bar).

The difference being in PySide2, it forces the tabs to be the preferred size, where I'd like the old behaviour of minimum size.

Edit: Example with minimal code. The sizeHint width stretches the tab across the full width, whereas in older Qt versions it doesn't do that. I can't really override tabSizeHint since I don't know what the original tab size should be.

import sys
from PySide2 import QtCore, QtWidgets

class TabBar(QtWidgets.QTabBar):
    def sizeHint(self):
        return QtCore.QSize(100000, super().sizeHint().height())

class Test(QtWidgets.QDialog):
    def __init__(self, parent=None):
        super(Test, self).__init__(parent)

        layout = QtWidgets.QVBoxLayout()
        self.setLayout(layout)

        tabWidget = QtWidgets.QTabWidget()
        tabWidget.setTabBar(TabBar())
        layout.addWidget(tabWidget)

        tabWidget.addTab(QtWidgets.QWidget(), 'this shouldnt be stretched')

if __name__ == '__main__':
    app = QtWidgets.QApplication(sys.argv)
    test = Test()
    test.show()
    sys.exit(app.exec_())
Peter
  • 3,186
  • 3
  • 26
  • 59
  • Is it possible to throw together a quick example? I am familiar with pyqt5, but not necessarily pyside. – Aaron Aug 04 '20 at 16:45
  • Hey, just did the quick example – Peter Aug 04 '20 at 21:41
  • Thanks for the example. It really does make it much easier to come up with the solution quickly. I even find myself answering my own problems sometimes when figuring out the exact minimal amount of code to reproduce the issue. As it turns out, pyside and pyqt are drop-in compatible for the most part. – Aaron Aug 04 '20 at 22:12
  • 1
    I use `Qt.py` to deal with any compatibility issues between `PySide`, `PyQt4`, `PySide2` and `PyQt5`, complete lifesaver at work when we still gotta use Python 2 for most things :) – Peter Aug 04 '20 at 22:16
  • I'm gonna have to look this over a bit later. Since my answer isn't right, I'm gonna delete for now. – Aaron Aug 04 '20 at 22:44
  • Alright thanks for your time on it anyway, it's just a little annoying as I'd found an almost perfect solution that `PySide2` broke haha. – Peter Aug 04 '20 at 22:55
  • I may have found a satisfactory solution: use a tab itself rather than a push-button as the "+" button. If you want it aligned right with your tabs aligned left, [this](https://stackoverflow.com/questions/37201443/move-two-qt-tabs-to-the-right-leave-rest-to-the-left/37203075) may contain the solution. – Aaron Aug 05 '20 at 21:46
  • Still on a campsite so will check your answer later, but in response to using a tab, I looked into it last year, if you enable re-ordering tabs, then it's impossible to treat the + tab separately, and putting in your own checks just makes it messy as the visual effect is still there – Peter Aug 08 '20 at 14:24
  • The linked solution uses 2 separate TabBars to solve that problem. I further discuss various options in my new answer. – Aaron Aug 08 '20 at 16:27

2 Answers2

1

I think there may be an easy solution to your problem (see below). Where the linked partial solution calculated absolute positioning for the '+' button, the real intent with Qt is always to let the layout engine do it's thing rather than trying to tell it specific sizes and positions. QTabWidget is basically a pre-built amalgamation of layouts and widgets, and sometimes you just have to skip the pre-built and build your own.

example of building a custom TabWidget with extra things across the TabBar:

import sys
from PySide2 import QtWidgets
from random import randint
    
class TabWidget(QtWidgets.QWidget):
    def __init__(self):
        super().__init__()
        #layout for entire widget
        vbox = QtWidgets.QVBoxLayout(self)
        
        #top bar:
        hbox = QtWidgets.QHBoxLayout()
        vbox.addLayout(hbox)
        
        self.tab_bar = QtWidgets.QTabBar()
        self.tab_bar.setMovable(True)
        hbox.addWidget(self.tab_bar)
        
        spacer = QtWidgets.QSpacerItem(0,0,QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Fixed)
        hbox.addSpacerItem(spacer)
        
        add_tab = QtWidgets.QPushButton('+')
        hbox.addWidget(add_tab)
        
        #tab content area:
        self.widget_stack = QtWidgets.QStackedLayout()
        vbox.addLayout(self.widget_stack)
        self.widgets = {}

        #connect events
        add_tab.clicked.connect(self.add_tab)
        self.tab_bar.currentChanged.connect(self.currentChanged)
        
    def add_tab(self):
        tab_text = 'tab' + str(randint(0,100))
        tab_index = self.tab_bar.addTab(tab_text)
        widget = QtWidgets.QLabel(tab_text)
        self.tab_bar.setTabData(tab_index, widget)
        
        self.widget_stack.addWidget(widget)
        
        self.tab_bar.setCurrentIndex(tab_index)

        
    def currentChanged(self, i):
        if i >= 0:
            self.widget_stack.setCurrentWidget(self.tab_bar.tabData(i))
        


if __name__ == '__main__':
    app = QtWidgets.QApplication(sys.argv)
    test = TabWidget()
    test.show()
    sys.exit(app.exec_())

All that said, I think the pre-built QTabWidget.setCornerWidget may be exactly what you're looking for (set a QPushButton to the upper-right widget). The example I wrote should much easier to customize, but also much more effort to re-implement all the same functionality. You will have to re-implement some of the signal logic to create / delete / select / rearrange tabs on your own. I only demonstrated simple implementation, which probably isn't bulletproof to all situations.

Aaron
  • 10,133
  • 1
  • 24
  • 40
  • Cheers, the size hint is required for the `+` button to fit so I can't remove it, I only set it to a high number to demonstrate the issue. Setting the alignment didn't stop the stretching however - https://i.imgur.com/ZBHxuZX.png (for reference, this is how it looks in `PySide` - https://i.imgur.com/wrGuHej.png) – Peter Aug 04 '20 at 22:12
  • @Peter I entirely re-wrote my answer after some additional research. I think the approach of manually hinting the size of the TabBar so that an absolute positioned button fits in the right space is not a sustainable solution. It would seem QTabWidget already has the ability to add custom buttons (or any widget really) to the corners, and I also built an example of writing your own version of TabWidget for greater flexibility. – Aaron Aug 06 '20 at 00:12
  • Thanks, this does seem more robust, if a little less nice looking. I had to make a few minor changes - move the spacer to after the `+` button, override the `minimumSizeHint` of the tab bar to always return `0` for the width, hide the tab bar when empty. I'll work on implementing it for my existing tool before selecting this answer just to check it all correctly holds up – Peter Aug 11 '20 at 12:59
  • Another minor issue I noticed when reimplementing the functionality - using `tabBar.tabAt(point)` works as if there are no `contentsMargins` on the layouts, so these need to be accounted for or removed – Peter Aug 11 '20 at 16:09
  • Thanks for all your help anyway, added my final code as an answer if its useful to anyone else – Peter Aug 11 '20 at 17:02
1

Using the code from Aaron as a base to start on, I managed to implement all the functionality required to work with my existing script:

from PySide2 import QtCore, QtWidgets

class TabBar(QtWidgets.QTabBar):
    def minimumSizeHint(self):
        """Allow the tab bar to shrink as much as needed."""
        minimumSizeHint = super(TabBar, self).minimumSizeHint()
        return QtCore.QSize(0, minimumSizeHint.height())

class TabWidgetPlus(QtWidgets.QWidget):

    tabOpenRequested = QtCore.Signal()
    tabCountChanged = QtCore.Signal(int)

    def __init__(self, parent=None):
        self._addingTab = False
        super(TabWidgetPlus, self).__init__(parent=parent)

        # Main layout
        layout = QtWidgets.QVBoxLayout(self)
        layout.setContentsMargins(0, 0, 0, 0)

        # Bar layout
        self._tabBarLayout = QtWidgets.QHBoxLayout()
        self._tabBarLayout.setContentsMargins(0, 0, 0, 0)
        self._tabBarLayout.setSpacing(0)
        layout.addLayout(self._tabBarLayout)

        self._tabBar = TabBar()
        self._tabBarLayout.addWidget(self._tabBar)
        for method in (
                'isMovable', 'setMovable',
                'tabsClosable', 'setTabsClosable',
                'tabIcon', 'setTabIcon',
                'tabText', 'setTabText',
                'currentIndex', 'setCurrentIndex',
                'currentChanged', 'tabCloseRequested',
            ):
            setattr(self, method, getattr(self._tabBar, method))

        self._plusButton = QtWidgets.QPushButton('+')
        self._tabBarLayout.addWidget(self._plusButton)  # TODO: Find location to insert
        self._plusButton.setFixedWidth(20)

        self._tabBarLayout.addStretch()

        # Content area
        self._contentArea = QtWidgets.QStackedLayout()
        layout.addLayout(self._contentArea)

        # Signals
        self.currentChanged.connect(self._currentChanged)
        self._plusButton.clicked.connect(self.tabOpenRequested.emit)

        # Final setup
        self.installEventFilter(self)

    @QtCore.Slot(int)
    def _currentChanged(self, i):
        """Update the widget."""
        if i >= 0 and not self._addingTab:
            self._contentArea.setCurrentWidget(self.tabBar().tabData(i))

    def eventFilter(self, obj, event):
        """Intercept events until the correct height is set."""
        if event.type() == QtCore.QEvent.Show:
            self.plusButton().setFixedHeight(self._tabBar.geometry().height())
            self.removeEventFilter(self)
        return False

    def tabBarLayout(self):
        return self._tabBarLayout

    def tabBar(self):
        return self._tabBar

    def plusButton(self):
        return self._plusButton

    def tabAt(self, point):
        """Get the tab at a given point.
        This takes any layout margins into account.
        """
        offset = self.layout().contentsMargins().top() + self.tabBarLayout().contentsMargins().top()
        return self.tabBar().tabAt(point - QtCore.QPoint(0, offset))

    def addTab(self, widget, name=''):
        """Add a new tab.

        Returns:
            Tab index as an int.
        """
        self._addingTab = True
        tabBar = self.tabBar()
        try:
            index = tabBar.addTab(name)
            tabBar.setTabData(index, widget)
            self._contentArea.addWidget(widget)

        finally:
            self._addingTab = False
        return index

    def insertTab(self, index, widget, name=''):
        """Inserts a new tab.
        If index is out of range, a new tab is appended.

        Returns:
            Tab index as an int.
        """
        self._addingTab = True
        tabBar = self.tabBar()
        try:
            index = tabBar.insertTab(index, name)
            tabBar.setTabData(index, widget)
            self._contentArea.insertWidget(index, widget)

        finally:
            self._addingTab = False
        return index

    def removeTab(self, index):
        """Remove a tab."""
        tabBar = self.tabBar()
        self._contentArea.removeWidget(tabBar.tabData(index))
        tabBar.removeTab(index)


if __name__ == '__main__':
    import sys
    import random

    app = QtWidgets.QApplication(sys.argv)
    test = TabWidgetPlus()

    test.addTab(QtWidgets.QPushButton(), 'yeah')
    test.insertTab(0, QtWidgets.QCheckBox(), 'what')
    test.insertTab(1, QtWidgets.QRadioButton(), 'no')
    test.removeTab(1)
    test.setMovable(True)
    test.setTabsClosable(True)

    def tabTest():
        name = 'Tab ' + str(random.randint(0, 100))
        index = test.addTab(QtWidgets.QLabel(name), name)
        test.setCurrentIndex(index)
    test.tabOpenRequested.connect(tabTest)
    test.tabCloseRequested.connect(lambda index: test.removeTab(index))

    test.show()
    sys.exit(app.exec_())

The one difference is if you're using tabWidget.tabBar().tabAt(point), this is no longer guaranteed to be correct as it doesn't take any margins into account. I set the margins to 0 so this shouldn't be an issue, but I also included those corrections in TabWidgetPlus.tabAt.

I only copied a few methods from QTabBar to QTabWidget as some may need extra testing.

Peter
  • 3,186
  • 3
  • 26
  • 59