Issue
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:
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)
andself.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:
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).
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).
Answered By - musicamante
0 comments:
Post a Comment
Note: Only a member of this blog may post a comment.