Search code examples
pythonpython-3.xpyqtpyqt5qtabwidget

Giving a color to single tab consumes too much processing power


After lots of research, I managed to customize the QTabWidget in PyQt5 (Python 3.6) such that I can assign a different color to an arbitrary tab:

enter image description here

Yes, I know that one can manipulate certain tabs using CSS-selectors like:

  • QTabBar::tab:selected
  • QTabBar::tab:hover
  • QTabBar::tab:selected
  • QTabBar::tab:!selected

But none of these selectors solves the actual problem I have. If I want to highlight the second tab - no matter if it is selected, hovered, ... - neither of these CSS-selectors help me.

I will now explain how I got it eventually working. After that, I'll show where the computation-intensive part is, and why I can't get that out. Hopefully you can help me to improve the efficiency.


The code

Below you can find the source code of my solution. To try it out yourself, just copy-paste the code into a new file (like tab_test.py) and run it. Below the code you find more explanations.

import sys

from PyQt5.QtWidgets import *
from PyQt5.QtCore import *
from PyQt5.QtGui import *


#########################################################
#             STYLESHEET FOR QTABWIDGET                 #
#########################################################
def get_QTabWidget_style():
    styleStr = str("""
        QTabWidget::pane {             
            border-width: 2px;         
            border-style: solid;       
            border-color: #0000ff;         
            border-radius: 6px;        
        }                              
        QTabWidget::tab-bar {          
            left: 5px;                 
        }                              
    """)
    return styleStr

#########################################################
#               STYLESHEET FOR QTABBAR                  #
#########################################################
def get_QTabBar_style():
    styleStr = str("""
        QTabBar {                                          
            background: #00ffffff;                         
            color: #ff000000;                              
            font-family: Courier;                          
            font-size: 12pt;                               
        }                                                  
        QTabBar::tab {                  
            background: #00ff00;                         
            color: #000000;                              
            border-width: 2px;                             
            border-style: solid;                           
            border-color: #0000ff;                             
            border-bottom-color: #00ffffff;                
            border-top-left-radius: 6px;                   
            border-top-right-radius: 6px;                  
            min-height: 40px;                              
            padding: 2px;                                  
        }                                                  
        QTabBar::tab:selected {                            
            border-color: #0000ff;                             
            border-bottom-color: #00ffffff;                
        }                                                  
        QTabBar::tab:!selected {                           
            margin-top: 2px;                               
        }                                                  
        QTabBar[colorToggle=true]::tab {                   
            background: #ff0000;                         
        }                                                  
    """)

    return styleStr


#########################################################
#                  SUBCLASS QTABBAR                     #
#########################################################
class MyTabBar(QTabBar):
    def __init__(self, *args, **kwargs):
        super(MyTabBar, self).__init__(*args, **kwargs)
        self.__coloredTabs = []
        self.setProperty("colorToggle", False)

    def colorTab(self, index):
        if (index >= self.count()) or (index < 0) or (index in self.__coloredTabs):
            return
        self.__coloredTabs.append(index)
        self.update()

    def uncolorTab(self, index):
        if index in self.__coloredTabs:
            self.__coloredTabs.remove(index)
            self.update()

    def paintEvent(self, event):
        painter = QStylePainter(self)
        opt = QStyleOptionTab()
        painter.save()

        for i in range(self.count()):
            self.initStyleOption(opt, i)
            if i in self.__coloredTabs:
                self.setProperty("colorToggle", True)
                self.style().unpolish(self)
                self.style().polish(self)

                painter.drawControl(QStyle.CE_TabBarTabShape, opt)
                painter.drawControl(QStyle.CE_TabBarTabLabel, opt)
            else:
                self.setProperty("colorToggle", False)
                self.style().unpolish(self)
                self.style().polish(self)

                painter.drawControl(QStyle.CE_TabBarTabShape, opt)
                painter.drawControl(QStyle.CE_TabBarTabLabel, opt)

        painter.restore()

#########################################################
#                SUBCLASS QTABWIDGET                    #
#########################################################
class MyTabWidget(QTabWidget):
    def __init__(self, *args, **kwargs):
        super(MyTabWidget, self).__init__(*args, **kwargs)
        self.myTabBar = MyTabBar()
        self.setTabBar(self.myTabBar)
        self.setTabsClosable(True)

        self.setStyleSheet(get_QTabWidget_style())
        self.tabBar().setStyleSheet(get_QTabBar_style())

    def colorTab(self, index):
        self.myTabBar.colorTab(index)

    def uncolorTab(self, index):
        self.myTabBar.uncolorTab(index)




'''=========================================================='''
'''|                  CUSTOM MAIN WINDOW                    |'''
'''=========================================================='''
class CustomMainWindow(QMainWindow):

    def __init__(self):
        super(CustomMainWindow, self).__init__()

        # -------------------------------- #
        #           Window setup           #
        # -------------------------------- #

        # 1. Define the geometry of the main window
        # ------------------------------------------
        self.setGeometry(100, 100, 800, 800)
        self.setWindowTitle("Custom TabBar test")

        # 2. Create frame and layout
        # ---------------------------
        self.__frm = QFrame(self)
        self.__frm.setStyleSheet("QWidget { background-color: #efefef }")
        self.__lyt = QVBoxLayout()
        self.__frm.setLayout(self.__lyt)
        self.setCentralWidget(self.__frm)

        # 3. Insert the TabMaster
        # ------------------------
        self.__tabMaster = MyTabWidget()
        self.__lyt.addWidget(self.__tabMaster)

        # 4. Add some dummy tabs
        # -----------------------
        self.__tabMaster.addTab(QFrame(), "first")
        self.__tabMaster.addTab(QFrame(), "second")
        self.__tabMaster.addTab(QFrame(), "third")
        self.__tabMaster.addTab(QFrame(), "fourth")

        # 5. Color a specific tab
        # ------------------------
        self.__tabMaster.colorTab(1)


        # 6. Show window
        # ---------------
        self.show()

    ''''''

'''=== end Class ==='''


if __name__ == '__main__':
    app = QApplication(sys.argv)
    QApplication.setStyle(QStyleFactory.create('Fusion'))
    myGUI = CustomMainWindow()
    sys.exit(app.exec_())

''''''

The code explained

1. Dynamic stylesheets
I've got a stylesheet for the QTabWidget and one for the QTabBar. The magic is in the last one. The background color of the tab (denoted by the CSS-selector QTabBar::tab) is generally green #00ff00. But when the colorToggle property is on, the color is set to red #ff0000.

 
2. class MyTabBar
I subclass QTabBar into a new class MyTabBar. In this way, I can do two things:

  • I add a function colorTab(index) such that external code can call it to color an arbitrary tab.

  • I override the paintEvent(event) function such that I can apply the color on the chosen tabs.

The colorTab(index) function simply takes an index and adds it to a list. That's it. The list will be checked in the overridden paintEvent(event) function.

After checking the list, the paintEvent(event) function decides whether it should set or clear the property "colorToggle":

    self.setProperty("colorToggle", True)

After setting (or clearing) this property, the paintEvent(event) function proceeds to paint the actual tab:

    self.style().unpolish(self)
    self.style().polish(self)

    painter.drawControl(QStyle.CE_TabBarTabShape, opt)
    painter.drawControl(QStyle.CE_TabBarTabLabel, opt)

 

I have noticed that self.style().unpolish(self) and self.style().polish(self) consume a lot of processing power. But deleting them results in failure. I don't know any (less computational-intensive) alternative.

 
3. class MyTabWidget
I've also subclassed the QTabWidget class. In its constructor, I replace the default QTabBar by my own subclassed MyTabBar. After that, I apply my stylesheets.

 
4. class CustomMainWindow
I create a main window (subclassed from QMainWindow) to simply test the new Tab Widget. That's very simple. I instantiate MyTabWidget() and insert some dummy tabs into it.
Then I color the second one (note: tab counting starts from 0).


The problem explained

The problem is all in the lines:

    self.style().unpolish(self)
    self.style().polish(self)

inside the overridden paintEvent(event) function. They take some execution time, which is a problem because the paintEvent function gets called very regularly. My processor runs at 14% for this simple example (I have a 4Ghz watercooled i7 processor). Such processor load is simply unacceptable.


The platform/environment

I'm running on:

  • Python 3.6.3
  • PyQt5
  • Windows 10 (but please feel free to post your solution if it works on Linux)

Apparently the widget-style seems to be important. On the last lines of the sample code, you can see:

    QApplication.setStyle(QStyleFactory.create('Fusion'))

That widget-style should be consistently the same - both on Windows and Linux. But again - please feel free to post your solution if it works on another non-Fusion style.


First proposed solution

I was recommended to take a look here: Qt TabWidget Each tab Title Background Color

A solution is proposed: Subclass QTabBar and override the paintEvent(event) function. That's quite similar to the solution I already have above, but the code inside the paintEvent(event) function is different. So I give it a try.

First, I translate the given C++ code into Python:

    def paintEvent(self, event):
        painter = QStylePainter(self)
        opt = QStyleOptionTab()

        for i in range(self.count()):
            self.initStyleOption(opt, i)
            if i in self.__coloredTabs:
                opt.palette.setColor(QPalette.Button, QColor("#ff0000"))
            painter.drawControl(QStyle.CE_TabBarTabShape, opt)
            painter.drawControl(QStyle.CE_TabBarTabLabel, opt)

Now I replace my previous paintEvent(event) function with this code. I run the file ... but all tabs are green :-(

There must be something I'm doing wrong?

EDIT :
Apparently the tab didn't color because I was mixing stylesheets with QPalette changes. I was suggested to comment out all calls to setStyleSheet(..) and try again. Indeed, the intended tab gets the new color. But I lose all my styles... So this won't really help me.


Second proposed solution

Musicamante has proposed a solution based on QStyleOption helper classes. Please look below to see his answer. I've inserted his solution into my own sample code:

import sys

from PyQt5.QtWidgets import *
from PyQt5.QtCore import *
from PyQt5.QtGui import *


#########################################################
#             STYLESHEET FOR QTABWIDGET                 #
#########################################################
def get_QTabWidget_style():
    styleStr = str("""
        QTabWidget::pane {             
            border-width: 2px;         
            border-style: solid;       
            border-color: #0000ff;         
            border-radius: 6px;        
        }                              
        QTabWidget::tab-bar {          
            left: 5px;                 
        }                              
    """)
    return styleStr

#########################################################
#               STYLESHEET FOR QTABBAR                  #
#########################################################
def get_QTabBar_style():
    styleStr = str("""
        QTabBar {                                          
            background: #00ffffff;                         
            color: #ff000000;                              
            font-family: Courier;                          
            font-size: 12pt;                               
        }                                                  
        QTabBar::tab {                  
            background: #00ff00;                         
            color: #000000;                              
            border-width: 2px;                             
            border-style: solid;                           
            border-color: #0000ff;                             
            border-bottom-color: #00ffffff;                
            border-top-left-radius: 6px;                   
            border-top-right-radius: 6px;                  
            min-height: 40px;                              
            padding: 2px 12px;                                  
        }                                                  
        QTabBar::tab:selected {                            
            border-color: #0000ff;                             
            border-bottom-color: #00ffffff;                
        }                                                  
        QTabBar::tab:!selected {                           
            margin-top: 2px;                               
        }                                                  
        QTabBar[colorToggle=true]::tab {                   
            background: #ff0000;                         
        }                                                  
    """)

    return styleStr


#########################################################
#                  SUBCLASS QTABBAR                     #
#########################################################
class MyTabBar(QTabBar):
    def __init__(self, parent):
        QTabBar.__init__(self, parent)
        self.colorIndexes = parent.colorIndexes

    def paintEvent(self, event):
        qp = QPainter(self)
        qp.setRenderHints(qp.Antialiasing)
        option = QStyleOptionTab()
        option.features |= option.HasFrame
        palette = option.palette
        for index in range(self.count()):
            self.initStyleOption(option, index)
            palette.setColor(palette.Button, self.colorIndexes.get(index, QColor(Qt.green)))
            palette.setColor(palette.Window, QColor(Qt.blue))
            option.palette = palette
            self.style().drawControl(QStyle.CE_TabBarTab, option, qp)


#########################################################
#                SUBCLASS QTABWIDGET                    #
#########################################################
class MyTabWidget(QTabWidget):
    def __init__(self):
        QTabWidget.__init__(self)
        self.colorIndexes = {
            1: QColor(Qt.red), 
            3: QColor(Qt.blue), 
            }
        self.setTabBar(MyTabBar(self))

        self.tabBar().setStyleSheet(get_QTabBar_style())
        self.setStyleSheet(get_QTabWidget_style())
        self.setTabsClosable(True)



'''=========================================================='''
'''|                  CUSTOM MAIN WINDOW                    |'''
'''=========================================================='''
class CustomMainWindow(QMainWindow):

    def __init__(self):
        super(CustomMainWindow, self).__init__()

        # -------------------------------- #
        #           Window setup           #
        # -------------------------------- #

        # 1. Define the geometry of the main window
        # ------------------------------------------
        self.setGeometry(100, 100, 800, 800)
        self.setWindowTitle("Custom TabBar test")

        # 2. Create frame and layout
        # ---------------------------
        self.__frm = QFrame(self)
        self.__frm.setStyleSheet("QWidget { background-color: #efefef }")
        self.__lyt = QVBoxLayout()
        self.__frm.setLayout(self.__lyt)
        self.setCentralWidget(self.__frm)

        # 3. Insert the TabMaster
        # ------------------------
        self.__tabMaster = MyTabWidget()
        self.__lyt.addWidget(self.__tabMaster)

        # 4. Add some dummy tabs
        # -----------------------
        self.__tabMaster.addTab(QFrame(), "first")
        self.__tabMaster.addTab(QFrame(), "second")
        self.__tabMaster.addTab(QFrame(), "third")
        self.__tabMaster.addTab(QFrame(), "fourth")

        # 5. Show window
        # ---------------
        self.show()

    ''''''

'''=== end Class ==='''


if __name__ == '__main__':
    app = QApplication(sys.argv)
    QApplication.setStyle(QStyleFactory.create('Fusion'))
    myGUI = CustomMainWindow()
    sys.exit(app.exec_())

''''''

The result gets pretty close to the desired outcome:

enter image description here

Musicamante says:

The only issue here is that the tab border does not use stylesheets (I wasn't able to find how QStyle draws them), so the radius is smaller and the pen width is thinner.

Thank you very much @musicamante! There is still one issue (the borders) but the result is the closest we ever got to the solution.


Solution

  • EDIT: After a lot of experience I've gained with QStyle, I suddenly remembered about this question due to another one that was recently posted, and realized why the "first proposed solution" linked in the question didn't work and mine either (for the same reasons, but using different implementation). Scroll down for the alternate solution.

    First (accepted) answer

    I stumbled upon similar issues some weeks ago, and then I studied a bit about how QStyle works. The concept is that you will let Qt draw the whole widget, but using QStyleOption helper classes (there's almost one for every kind of widget).

    screenshot of the proposed solution

    Here's a simple example (I updated the code), using part of the stylesheets you used. The only issue here is that the tab border does not properly use stylesheets (I wasn't able to find how QStyle draws them), so the radius is smaller and the pen width is thinner.

    I tested it and it works without consuming resources. I hope it helps.

    class TabBar(QtWidgets.QTabBar):
        def __init__(self, parent):
            QtWidgets.QTabBar.__init__(self, parent)
            self.colorIndexes = parent.colorIndexes
            self.setStyleSheet('''
                QTabBar {
                    font-family: Courier;
                    font-size: 12pt;
                }
                QTabBar::tab {
                    min-height: 40px;
                    padding: 2px 8px;
                }
                ''')
    
        def paintEvent(self, event):
            qp = QtGui.QPainter(self)
            qp.setRenderHints(qp.Antialiasing)
            option = QtWidgets.QStyleOptionTab()
            option.features |= option.HasFrame
            palette = option.palette
            for index in range(self.count()):
                self.initStyleOption(option, index)
                palette.setColor(palette.Button, self.colorIndexes.get(index, QtGui.QColor(QtCore.Qt.green)))
                palette.setColor(palette.Window, QtGui.QColor(QtCore.Qt.blue))
                option.palette = palette
                self.style().drawControl(QtWidgets.QStyle.CE_TabBarTab, option, qp)
    
    
    class TabWidget(QtWidgets.QTabWidget):
        def __init__(self):
            QtWidgets.QTabWidget.__init__(self)
            self.setStyleSheet('''
                QTabWidget::pane {
                    border: 2px solid blue;
                    border-radius: 6px;
                }
                QTabWidget::tab-bar {
                    left: 5px;
                }
                ''')
            self.colorIndexes = {
                1: QtGui.QColor(QtCore.Qt.red), 
                3: QtGui.QColor(QtCore.Qt.blue), 
                }
            self.setTabBar(TabBar(self))
            for i in range(5):
                w = QtWidgets.QWidget()
                self.addTab(w, 'tab {}'.format(i))
    
    
    app = QtWidgets.QApplication(sys.argv)
    QtWidgets.QApplication.setStyle('Fusion')
    w = TabWidget()
    w.show()
    sys.exit(app.exec_())
    

    Note: this example only works using Fusion style. Breeze doesn't use palette.Button but palette.Window instead; this means that you might be able to find other palette role combinations in other styles, which might result in a result that better meets your requirements. I don't know if it's actually possible to draw the tab borders through QStyle; if you absolutely need the borders the alternative is to draw them yourself, taking the various content sizes from QStyle.subElementRect().

    Alternate (updated and improved) solution

    The problem is that, when working with Qt's stylesheets, the optional widget argument of the QStyle functions is really important, because they rely almost completely on the widget's stylesheet to draw its shapes and colors (and compute its metrics), while usually ignoring the palette.

    I'd like to add an alternate answer, a workaround which actually is a small "hack", but that, most importantly, solves the inconsistence with the tab border by painting the tab bar exactly as expected.
    Also, it seems to be style independent: I've tried it with Breeze, Oxygen, Windows and Fusion styles, and it always gives the same, expected result.

    The trick is to create a "private" QTabBar widget (with no parent, to ensure that it won't be shown) that acts as a "proxy", and apply a custom stylesheet to it, which has a default background set; then, if the tab that is going to be painted is one of the "colored" tabs, it uses that internal QTabBar widget as an argument of the drawControl function. I've created an example that can colorize each tab with different colors, but you can just use one if you don't need that level of complexity, obviously.
    The important difference here is that we're using a plain QPainter instead of QStylePainter, whose functions wouldn't allow us to set another widget as argument.

    def get_QTabBar_style(background='#00ff00'):
        styleStr = str('''
            QTabBar {{
                background: #00ffffff;
                color: #ff000000;
                font-family: Courier;
                font-size: 12pt;
            }}
            QTabBar::tab {{
                background: {};
                color: #000000;
                border-width: 2px;
                border-style: solid;
                border-color: #0000ff;
                border-bottom-color: #00ffffff;
                border-top-left-radius: 6px;
                border-top-right-radius: 6px;
                min-height: 40px;
                padding: 2px;
            }}
            QTabBar::tab:selected {{
                border-color: #0000ff;
                border-bottom-color: #00ffffff;
            }}
            QTabBar::tab:!selected {{
                margin-top: 2px;
            }}
        '''.format(background))
    
        return styleStr
    
    
    class MyTabBar(QtWidgets.QTabBar):
        def __init__(self, parent):
            QtWidgets.QTabBar.__init__(self, parent)
            self.setStyleSheet(get_QTabBar_style())
            self.__coloredTabs = {}
    
        def colorTab(self, index, color='#ff0000'):
            if not 0 <= index < self.count():
                return
            proxy = self.__coloredTabs.get(index)
            if not proxy:
                proxy = self.__coloredTabs[index] = QtWidgets.QTabBar()
            proxy.setStyleSheet(get_QTabBar_style(color))
            self.update()
    
        def uncolorTab(self, index):
            try:
                self.__coloredTabs.pop(index)
                self.update()
            except:
                return
    
        def paintEvent(self, event):
            painter = QtGui.QPainter(self)
            opt = QtWidgets.QStyleOptionTab()
    
            for i in range(self.count()):
                self.initStyleOption(opt, i)
                self.style().drawControl(
                    QtWidgets.QStyle.CE_TabBarTabShape, opt, painter, 
                    self.__coloredTabs.get(i, self))
                self.style().drawControl(
                    QtWidgets.QStyle.CE_TabBarTabLabel, opt, painter, self)
    
    
    class MyTabWidget(QtWidgets.QTabWidget):
        def __init__(self):
            QtWidgets.QTabWidget.__init__(self)
            self.setStyleSheet(get_QTabWidget_style())
            tabBar = MyTabBar(self)
            self.setTabBar(tabBar)
            self.colorTab = tabBar.colorTab
            self.uncolorTab = tabBar.uncolorTab
    

    As you can see, the result is almost perfect (except for the small margin between tab bar and tab contents, which I'm afraid is style and OS dependent). screenshot of the improved solution