Force QTabBar tabs to stay as small as possible and ignore sizeHint

1k Views Asked by At

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_())
3

There are 3 best solutions below

5
Aaron On BEST ANSWER

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.

0
Peter On

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.

0
C0oo1D On

Not found anywhere the correct answer on that question, but during experiments came to this conclusion: all attempts to shrink tabs will fail, since no way has been found to set SizeHint of each tab.

But QTabWidget do this by default, so.. why not use it and add some code to handle 'plus tab'?

Simplified example:

import sys
from PySide6.QtWidgets import QApplication, QTabWidget, QLabel, QInputDialog


class Tabs(QTabWidget):
    def __init__(self):
        super().__init__()
        self.prev_tab_index = 0
        self.tab_change_ignore = False

        # Add at least one normal tab
        self.tab_all = QLabel('All')
        self.addTab(self.tab_all, 'All')

        # Access tabs directly, and add 'plus tab' without its widget
        tabs = self.tabBar()
        tabs.addTab('+')
        tabs.currentChanged.connect(self.tab_change)

    def tab_change(self, i):
        # Ignore internal manipulations
        if self.tab_change_ignore:
            return

        # Handle 'plus tab'
        if self.count() - i == 1:
            self.tab_change_ignore = True
            if name := QInputDialog.getText(self, 'Adding tab', 'Enter tab name:')[0]:
                self.insertTab(i, QLabel(name), name)
                self.setCurrentIndex(i)
            else:
                self.setCurrentIndex(self.prev_tab_index)
            self.tab_change_ignore = False
            
        # Handle other tabs
        else:
            self.setCurrentIndex(i)
            self.prev_tab_index = i


if __name__ == '__main__':
    app = QApplication(sys.argv)
    test = Tabs()
    test.show()
    sys.exit(app.exec())

prev_tab_index and tab_change_ignore is also used for closable tabs in my project, and I don't rewrite its logic. But everything seems to work as expected.

Also, for provided logic - at least one tab must be added, excepting 'plus tab'!