I've made a custom StyleItemDelegate and for some reason when it paints the checkbox indicator, it doesn't match what is defined in my Stylesheet. How can I fix this? You can see on the right side of the application that the StyleSheet properly affects how the checkbox is displayed in the default listview paint event.
Update #1
I've made a custom style item delegate to support rich text html rendering which all works great. I need to re-implement the checkbox since i've overwritten the paint event and ensure the checkbox is still available. However my text is overlapping the checkbox making it un useable. As a result when trying to paint the checkbox indicator, the highlighting of the ListItem is broken and only shows a slim blue strip on the left side.
Screenshot
Code
################################################################################
# imports
################################################################################
import os
import sys
from PySide2 import QtGui, QtWidgets, QtCore
################################################################################
# QStyledItemDelegate
################################################################################
class MyDelegate(QtWidgets.QStyledItemDelegate):
MARGINS = 10
def __init__(self, parent=None, *args):
QtWidgets.QStyledItemDelegate.__init__(self, parent, *args)
# overrides
def sizeHint(self, option, index):
'''
Description:
Since labels are stacked we will take whichever is the widest
'''
options = QtWidgets.QStyleOptionViewItem(option)
self.initStyleOption(options, index)
# draw rich text
doc = QtGui.QTextDocument()
doc.setHtml(index.data(QtCore.Qt.DisplayRole))
doc.setDocumentMargin(self.MARGINS)
doc.setDefaultFont(options.font)
doc.setTextWidth(option.rect.width())
return QtCore.QSize(doc.idealWidth(), doc.size().height())
# methods
def paint(self, painter, option, index):
painter.save()
painter.setClipping(True)
painter.setClipRect(option.rect)
opts = QtWidgets.QStyleOptionViewItem(option)
self.initStyleOption(opts, index)
style = QtGui.QApplication.style() if opts.widget is None else opts.widget.style()
# Draw background
if option.state & QtWidgets.QStyle.State_Selected:
painter.fillRect(option.rect, option.palette.highlight().color())
else:
painter.fillRect(option.rect, QtGui.QBrush(QtCore.Qt.NoBrush))
# Draw checkbox
if (index.flags() & QtCore.Qt.ItemIsUserCheckable):
cbStyleOption = QtWidgets.QStyleOptionButton()
if index.data(QtCore.Qt.CheckStateRole):
cbStyleOption.state |= QtWidgets.QStyle.State_On
else:
cbStyleOption.state |= QtWidgets.QStyle.State_Off
cbStyleOption.state |= QtWidgets.QStyle.State_Enabled
cbStyleOption.rect = option.rect.translated(self.MARGINS, 0)
style.drawControl(QtWidgets.QStyle.CE_CheckBox, cbStyleOption, painter, option.widget)
# Draw Title
doc = QtGui.QTextDocument()
doc.setHtml(index.data(QtCore.Qt.DisplayRole))
doc.setTextWidth(option.rect.width())
doc.setDocumentMargin(self.MARGINS)
doc.setDefaultFont(opts.font)
ctx = QtGui.QAbstractTextDocumentLayout.PaintContext()
# highlight text
if option.state & QtWidgets.QStyle.State_Selected:
ctx.palette.setColor(option.palette.Text, option.palette.color(option.palette.Active, option.palette.HighlightedText))
else:
ctx.palette.setColor(option.palette.Text, option.palette.color(option.palette.Active, option.palette.Text))
textRect = style.subElementRect(QtWidgets.QStyle.SE_ItemViewItemText, option)
painter.translate(textRect.topLeft())
painter.setClipRect(textRect.translated(-textRect.topLeft()))
doc.documentLayout().draw(painter, ctx)
# end
painter.restore()
################################################################################
# Widgets
################################################################################
class ListViewExample(QtWidgets.QWidget):
'''
Description:
Extension of listview which supports searching
'''
def __init__(self, parent=None):
super(ListViewExample, self).__init__(parent)
self.setStyleSheet('''
QListView {
color: rgb(255,255,255);
background-color: rgb(60,60,60);
}
QCheckBox, QCheckBox:disabled {
background: transparent;
}
QWidget::indicator {
width: 12px;
height: 12px;
border: 2px solid rgb(90,90,90);
border-radius: 3px;
background: rgb(30,30,30);
}
QWidget::indicator:checked {
border: 2px solid rgb(76,175,80);
background: rgb(0,255,40);
}
''')
self.itemModel = QtGui.QStandardItemModel()
self.checkbox = QtWidgets.QCheckBox('Sample')
self.listView = QtWidgets.QListView()
self.listView.setIconSize(QtCore.QSize(128,128))
self.listView.setModel(self.itemModel)
self.listView.setVerticalScrollMode(QtWidgets.QAbstractItemView.ScrollPerPixel)
self.checkboxA = QtWidgets.QCheckBox('Sample')
self.listViewA = QtWidgets.QListView()
self.listViewA.setIconSize(QtCore.QSize(128,128))
self.listViewA.setModel(self.itemModel)
self.listViewA.setVerticalScrollMode(QtWidgets.QAbstractItemView.ScrollPerPixel)
# layout
self.mainLayout = QtWidgets.QGridLayout()
self.mainLayout.addWidget(self.checkbox,0,0)
self.mainLayout.addWidget(self.listView,1,0)
self.mainLayout.addWidget(self.checkboxA,0,1)
self.mainLayout.addWidget(self.listViewA,1,1)
self.setLayout(self.mainLayout)
################################################################################
# Widgets
################################################################################
def main():
app = QtWidgets.QApplication(sys.argv)
window = ListViewExample()
window.resize(600,400)
window.listView.setItemDelegate(MyDelegate())
window.itemModel.clear()
for i in range(10):
html = '''
<span style="font-size:12px;">
<b> Player <span>•</span> #{}</b>
</span>
<br>
<span style="font-size:11px;">
<b>Status:</b> <span style='color:rgb(255,0,0);'>⬤</span> Active
<br>
<b>Position:</b> WR
<br>
<b>Team:</b> <span style='color:rgb(0,128,255);'>█</span> Wings
</span>
'''.format(i)
item = QtGui.QStandardItem()
item.setData(html, QtCore.Qt.DisplayRole)
item.setCheckable(True)
window.itemModel.appendRow(item)
window.show()
app.exec_()
if __name__ == '__main__':
pass
main()
QStyle painting functions assume that you're using the default behavior of that style for the control/primitive. This means that, in most cases, the last widget argument can be ignored, since no overriding is being considered.
When using stylesheets, instead, things change. If the widget or any of its parents (including the QApplication) has a style sheet, the widget will use an internal QStyleSheetStyle, inheriting the behavior of the QApplication style, and overridden by any style sheet set for the parents.
In this case, that argument becomes mandatory, as the underlying QStyleSheetStyle will need to check if the widget has (or inherits) a stylesheet and eventually "climb" the widget tree in order to know if and how any custom styling has been set for it.
You just need to add that to the arguments of the function:
style.drawControl(QtWidgets.QStyle.CE_CheckBox, cbStyleOption, painter,
option.widget)
^^^^^^^^^^^^^
The above solves the issue of the styling, but not of the drawing.
While the appearance of a check box in an item view is usually the same of a QCheckBox (and the ::indicator pseudo selector can be used for both), they are actually painted with different functions by the style.
The problem of your implementation is that you're drawing with drawControl and CE_CheckBox, but for item views you must use drawPrimitive and PE_IndicatorItemViewItemCheck. Also, since you're just translating the original option rect, the result is that drawControl will paint over a rectangle that is big as the whole item rectangle (thus painting over the selection).
The proper solution is to create a new QStyleOptionViewItem based on the existing one, and use the rectangle returned by subElementRect with SE_ItemViewItemCheckIndicator.
def paint(self, painter, option, index):
if (index.flags() & QtCore.Qt.ItemIsUserCheckable):
cbStyleOption = QtWidgets.QStyleOptionViewItem(opts)
if index.data(QtCore.Qt.CheckStateRole):
cbStyleOption.state |= QtWidgets.QStyle.State_On
else:
cbStyleOption.state |= QtWidgets.QStyle.State_Off
cbStyleOption.state |= QtWidgets.QStyle.State_Enabled
cbStyleOption.rect = style.subElementRect(
style.SE_ItemViewItemCheckIndicator, opts, opts.widget)
style.drawPrimitive(style.PE_IndicatorItemViewItemCheck,
cbStyleOption, painter, opts.widget)
Note that you should not use translation in the painting, as that would make it inconsistent with the mouse interaction.
To translate the element, use the top and left properties in the stylesheet instead:
QWidget::indicator {
left: 10px;
...
}
Also note that you're getting the text rectangle using the original option, which is not initialized, so that could return an invalid rectangle; you should then use the actually initialized option and also use the widget argument as explained above:
textRect = style.subElementRect(QtWidgets.QStyle.SE_ItemViewItemText,
opts, opts.widget)
Related
I am trying to override the paintEvent() of QMenu to make it have rounded corners.
The context menu should look something like this.
Here is the code I have tried But nothing appears:
from PyQt5 import QtWidgets, QtGui, QtCore
import sys
class Example(QtWidgets.QMainWindow):
def __init__(self):
super().__init__()
self.initUI()
def initUI(self):
self.setGeometry(300, 300, 300, 200)
self.setWindowTitle('Context menu')
self.show()
def contextMenuEvent(self, event):
cmenu = AddContextMenu(self)
newAct = cmenu.addAction("New")
openAct = cmenu.addAction("Open")
quitAct = cmenu.addAction("Quit")
action = cmenu.exec_(self.mapToGlobal(event.pos()))
class AddContextMenu(QtWidgets.QMenu):
def __init__(self, *args, **kwargs):
super(AddContextMenu, self).__init__()
self.painter = QtGui.QPainter(self)
self.setMinimumSize(150, 200)
self.pen = QtGui.QPen(QtCore.Qt.red)
#self.setStyleSheet('color:white; background:gray; border-radius:4px; border:2px solid white;')
def paintEvent(self, event) -> None:
self.pen.setWidth(2)
self.painter.setPen(self.pen)
self.painter.setBrush(QtGui.QBrush(QtCore.Qt.blue))
self.painter.drawRoundedRect(10, 10, 100, 100, 4.0, 4.0)
self.update()
#self.repaint()
#super(AddContextMenu, self).paintEvent(event)
def main():
app = QtWidgets.QApplication(sys.argv)
ex = Example()
sys.exit(app.exec_())
if __name__ == '__main__':
main()
Note: setting a style sheet doesn't work for me:
this is what I get when using the style sheet It isn't completely rounded.
This is the paintEvent after #musicamante suggestion(This is just for him/her to check)
def paintEvent(self, event) -> None:
painter = QtGui.QPainter(self)
#self.pen.setColor(QtCore.Qt.white)
#painter.setFont(QtGui.QFont("times", 22))
#painter.setPen(self.pen)
#painter.drawText(QtCore.QPointF(0, 0), 'Hello')
self.pen.setColor(QtCore.Qt.red)
painter.setPen(self.pen)
painter.setBrush(QtCore.Qt.gray)
painter.drawRoundedRect(self.rect(), 20.0, 20.0)
and in the init()
self.pen = QtGui.QPen(QtCore.Qt.red)
self.pen.setWidth(2)
I cannot comment on the paintEvent functionality, but it is possible to implement rounded corners using style-sheets. Some qmenu attributes have to be modified in order to disable the default rectangle in the background, which gave you the unwanted result.
Here is a modified version of your Example using style-sheets + custom flags (no frame + transparent background):
from PyQt5 import QtWidgets, QtCore
import sys
class Example(QtWidgets.QMainWindow):
def __init__(self):
super().__init__()
self.initUI()
def initUI(self):
self.setGeometry(300, 300, 300, 200)
self.setWindowTitle('Context menu')
self.show()
def contextMenuEvent(self, event):
cmenu = QtWidgets.QMenu()
# disable default frame and background
cmenu.setWindowFlags(QtCore.Qt.FramelessWindowHint)
cmenu.setAttribute(QtCore.Qt.WA_TranslucentBackground)
# set stylesheet, add some padding to avoid overlap of selection with rounded corner
cmenu.setStyleSheet("""
QMenu{
background-color: rgb(255, 255, 255);
border-radius: 20px;
}
QMenu::item {
background-color: transparent;
padding:3px 20px;
margin:5px 10px;
}
QMenu::item:selected { background-color: gray; }
""")
newAct = cmenu.addAction("New")
openAct = cmenu.addAction("Open")
quitAct = cmenu.addAction("Quit")
action = cmenu.exec_(self.mapToGlobal(event.pos()))
def main():
app = QtWidgets.QApplication(sys.argv)
ex = Example()
sys.exit(app.exec_())
if __name__ == '__main__':
main()
Setting the border radius in the stylesheet for a top level widget (a widget that has its own "window") is not enough.
While the solution proposed by Christian Karcher is fine, two important considerations are required:
The system must support compositing; while this is true for most modern OSes, at least on Linux there is the possibility that even an up-to-date system does not support it by choice (I disabled on my computer); if that's the case, setting the WA_TranslucentBackground attribute will not work.
The FramelessWindowHint should not be set on Linux, as it may lead to problems with the window manager, so it should be set only after ensuring that the OS requires it (Windows).
In light of that, using setMask() is the correct fix whenever compositing is not supported, and this has to happen within the resizeEvent(). Do note that masking is bitmap based, and antialiasing is not supported, so rounded borders are sometimes a bit ugly depending on the border radius.
Also, since you want custom colors, using stylesheets is mandatory, as custom painting of a QMenu is really hard to achieve.
class AddContextMenu(QtWidgets.QMenu):
def __init__(self, *args, **kwargs):
super(AddContextMenu, self).__init__()
self.setMinimumSize(150, 200)
self.radius = 4
self.setStyleSheet('''
QMenu {{
background: blue;
border: 2px solid red;
border-radius: {radius}px;
}}
QMenu::item {{
color: white;
}}
QMenu::item:selected {{
color: red;
}}
'''.format(radius=self.radius))
def resizeEvent(self, event):
path = QtGui.QPainterPath()
# the rectangle must be translated and adjusted by 1 pixel in order to
# correctly map the rounded shape
rect = QtCore.QRectF(self.rect()).adjusted(.5, .5, -1.5, -1.5)
path.addRoundedRect(rect, self.radius, self.radius)
# QRegion is bitmap based, so the returned QPolygonF (which uses float
# values must be transformed to an integer based QPolygon
region = QtGui.QRegion(path.toFillPolygon(QtGui.QTransform()).toPolygon())
self.setMask(region)
Some side notes about your paintEvent implementation, not necessary in this specific case for the above reason, but still important (some points are related to portions of code that have been commented, but the fact that you tried them makes worth mentioning those aspects):
The QPainter used for a widget must never be instanciated outside a paintEvent(): creating the instance in the __init__ as you did is a serious error and might even lead to crash. The painter can only be created when the paintEvent is received, and shall never be reused. This clearly makes useless to set it as an instance attribute (self.painter), since there's no actual reason to access it after the paint event.
If the pen width is always the same, then just set it in the constructor (self.pen = QtGui.QPen(QtCore.Qt.red, 2)), continuously setting it in the paintEvent is useless.
QPen and QBrush can directly accept Qt global colors, so there's no need to create a QBrush instance as the painter will automatically (internally and fastly) set it: self.painter.setBrush(QtCore.Qt.blue).
self.update() should never be called within a paintEvent (and not even self.repaint() should). The result in undefined and possibly dangerous.
If you do some manual painting with a QPainter and then call the super paintEvent, the result is most likely that everything painted before will be hidden; as a general rule, the base implementation should be called first, then any other custom painting should happen after (in this case it obviously won't work, as you'll be painting a filled rounded rect, making the menu items invisible).
I have implemented round corners menu using QListWidget and QWidget. You can download the code in https://github.com/zhiyiYo/PyQt-Fluent-Widgets/blob/master/examples/menu/demo.py.
How can i fix the QLabel to not clip the text when resizing? This is a widget that will be placed inside a QDialog eventually. So the resizing of the Dialog will happen if a user resizes the main dialog.
'''
Main Navigation bar
'''
################################################################################
# imports
################################################################################
import os
import sys
import inspect
from PySide2 import QtWidgets, QtCore, QtGui
################################################################################
# widgets
################################################################################
class Context(QtWidgets.QWidget):
def __init__(self):
super(Context, self).__init__()
# controls
self.uiThumbnail = QtWidgets.QLabel()
self.uiThumbnail.setMinimumSize(QtCore.QSize(100, 75))
self.uiThumbnail.setMaximumSize(QtCore.QSize(100, 75))
self.uiThumbnail.setScaledContents(True)
self.uiThumbnail.setAlignment(QtCore.Qt.AlignLeading | QtCore.Qt.AlignLeft | QtCore.Qt.AlignTop)
self.uiThumbnail.setObjectName('thumbnail')
self.uiDetailsText = QtWidgets.QLabel()
self.uiDetailsText.setAlignment(QtCore.Qt.AlignLeading | QtCore.Qt.AlignLeft | QtCore.Qt.AlignTop)
self.uiDetailsText.setWordWrap(True)
self.uiDetailsText.setTextInteractionFlags(QtCore.Qt.TextBrowserInteraction)
self.uiDetailsText.setOpenExternalLinks(True)
self.uiMenuButton = QtWidgets.QPushButton()
self.uiMenuButton.setFixedSize(QtCore.QSize(24, 24))
self.uiMenuButton.setFocusPolicy(QtCore.Qt.NoFocus)
# header layout
self.headerLayout = QtWidgets.QHBoxLayout()
self.headerLayout.setSpacing(6)
self.headerLayout.setContentsMargins(6, 6, 6, 6)
self.headerLayout.setAlignment(QtCore.Qt.AlignTop)
self.headerLayout.addWidget(self.uiThumbnail)
self.headerLayout.addWidget(self.uiDetailsText)
self.headerLayout.addWidget(self.uiMenuButton)
self.headerLayout.setAlignment(self.uiThumbnail, QtCore.Qt.AlignTop)
self.headerLayout.setAlignment(self.uiMenuButton, QtCore.Qt.AlignTop)
# frames
self.headerFrame = QtWidgets.QFrame()
self.headerFrame.setObjectName('panel')
self.headerFrame.setFrameShape(QtWidgets.QFrame.StyledPanel)
self.headerFrame.setFrameShadow(QtWidgets.QFrame.Raised)
self.headerFrame.setLayout(self.headerLayout)
# layout
self.mainLayout = QtWidgets.QHBoxLayout()
self.mainLayout.setSpacing(6)
self.mainLayout.setContentsMargins(0, 0, 0, 0)
self.mainLayout.addWidget(self.headerFrame)
self.setLayout(self.mainLayout)
self.setStyleSheet('''
#thumbnail {
background-color: rgb(70,70,70);
}
#panel {
background-color: rgb(120,120,120);
border-radius:3px;
}
''')
self.updateContext()
# methods
def updateContext(self):
self.uiDetailsText.setText('''
<span style="font-size:14px;">
<b>A title goes here which can wrap</b>
</span>
<br>
<span style="font-size:11px;">
<b>Status:</b> Additional details go here
<br>
<b>User:</b>
User information goes here
<br>
<b>About:</b> Some more information
<br>
<b>Date:</b> 2021-07-03
<br>
</span>
''')
################################################################################
# main
################################################################################
if __name__ == '__main__':
pass
app = QtWidgets.QApplication(sys.argv)
ex = Context()
ex.resize(500,70)
ex.show()
sys.exit(app.exec_())
I tried adding this and it didn't help at all...
# methods
def resizeEvent(self, event):
newHeight = self.uiDetailsText.heightForWidth(self.uiDetailsText.width())
self.uiDetailsText.setMaximumHeight(newHeight)
event.accept()
The size policy of a QLabel is always Preferred (in both directions), and if word wrapping or rich text is used, the text layout engine will try to find an optimal width based on the contents. This unfortunately creates some issues in layout managers, as explained in the layout documentation:
The use of rich text in a label widget can introduce some problems to the layout of its parent widget. Problems occur due to the way rich text is handled by Qt's layout managers when the label is word wrapped.
Also consider the following line:
self.headerLayout.setAlignment(QtCore.Qt.AlignTop)
it will align the layout item (headerLayout) to the top of the headerFrame, which creates problems with the size hint of the label, since the size policy is Preferred. It's normally unnecessary (and often discouraged) to set the alignment of a layout if it's the top level layout of a widget.
Unfortunately, there's no easy solution for these situations, because:
to allow "free" resizing, the label cannot have any size constraints;
as soon as a widget is mapped, the size hint is just that: a hint; if the user resizes a window that contains wrapped text, the size choosen by the user is respected, even if that results in partially hiding the text;
the size hint considers the heightForWidth of children only when the layout is activated, after that the top level window will ignore any hint and will only honor the user choice, limited by the minimum size (or minimum size hint) of widgets;
heightForWidth() is called only for widgets that do not have a layout, otherwise the layout item's heightForWidth() will be called;
There are workarounds, but they are not always reliable.
A possible solution is to resize the top level window whenever the height doesn't respect the widget's heightForWidth(). Note that this is not completely reliable, and it's not 100% safe, as it requires to call a resize inside a resize event. If any of the parent has some functions that acts on delayed calls related to layouts (including changing the geometry), it might cause recursion problems.
class Context(QtWidgets.QWidget):
resizing = False
# ...
def resizeEvent(self, event):
super().resizeEvent(event)
if self.resizing:
return
diff = self.heightForWidth(event.size().width()) - self.height()
if diff > 0:
self.resizing = True
target = self
while not target.windowFlags() & (QtCore.Qt.Window | QtCore.Qt.SubWindow):
target = target.parent()
target.resize(target.width(), target.height() + diff)
self.resizing = False
Note that for this to work you still have to set the Expanding vertical size policy or remove the line for the layout alignment.
Most importantly, this can only be used only for a single widget in a window, so you should consider implementing it in the top level widget.
I want to change the color of selected item in QListItem, and I find qss may be a solution. And the code is:
from PyQt5.QtCore import *
from PyQt5.QtWidgets import *
from PyQt5.QtGui import *
import sys
class Window(QWidget):
def __init__(self):
super().__init__()
with open('mainStyle.qss', 'r', encoding='utf-8') as file:
self.setStyleSheet(file.read())
# self.setStyleSheet('*{font-size: 15px;background-color: rgb(150, 150, 150);}'
# 'QListWidget::item:selected{background: rgb(128,128,255);}')
self.setStyleSheet('QListWidget::item:selected{background: rgb(128,128,255);}')
layout = QVBoxLayout()
self.setLayout(layout)
listWidget = QListWidget()
layout.addWidget(listWidget)
w1 = QWidget()
w1Item = QListWidgetItem()
w1Item.setSizeHint(QSize(150, 150))
listWidget.insertItem(0, w1Item)
listWidget.setItemWidget(w1Item, w1)
w2 = QWidget()
w2Item = QListWidgetItem()
w2Item.setSizeHint(QSize(150, 150))
listWidget.insertItem(1, w2Item)
listWidget.setItemWidget(w2Item, w2)
if __name__ == '__main__':
app = QApplication(sys.argv)
win = Window()
win.show()
app.exec_()
We can see that the color is changed to be blue when the item is selected.
However, I need to provide a general background color for other widgets. So I change the style from
self.setStyleSheet('QListWidget::item:selected{background: rgb(0,0,255);}')
to
self.setStyleSheet('*{font-size: 15px;background-color: rgb(150, 150, 150);}'
'QListWidget::item:selected{background: rgb(0,0,0);}')
Then, I find QListWidget::item:selected do not work. The color do not change when I select a item.
What's the wrong with my code?
The problem is that you're setting a QWidget for the item, and since you're using a universal selector (with the wildcard), the result is that all QWidget will have that background color, including those added as item widgets for the list view.
The color you used for the :selected pseudo is only valid for the item painted by the view, since the item widget has its own background set from the universal selector, that background won't be visible.
The solution is to use a proper selector combination ensuring that the rule only matches children of the list view that have a usable selector, and set a transparent color for those widgets.
A possibility is to set a custom property for the widgets that must be set before adding the widgets to the list (otherwise, you need to set the stylesheet after adding them, or request a style.unpolish()).
self.setStyleSheet('''
QWidget {
font-size: 15px;
background: rgb(150, 150, 150);
}
QListWidget::item:selected {
background: rgb(128,128,255);
}
QListWidget QWidget[widgetItem=true] {
background: transparent;
}
''')
# ...
w1 = QWidget()
w1.setProperty('widgetItem', True)
# ...
w2 = QWidget()
w2.setProperty('widgetItem', True)
# ...
Another way to do so is to use an "empty" subclass for widgets that are going to be added to the item view:
class CustomItemWidget(QWidget): pass
class Window(QWidget):
def __init__(self):
super().__init__()
self.setStyleSheet('''
QWidget {
font-size: 15px;
background: rgb(150, 150, 150);
}
QListWidget::item:selected {
background: rgb(128,128,255);
}
CustomItemWidget {
background: transparent;
}
''')
# ...
w1 = CustomItemWidget()
# ...
w2 = CustomItemWidget()
# ...
Consider that using universal selectors for colors is usually not a good idea, as it can result in inconsistent behavior, especially for complex widgets: for instance, the scroll bars of scroll areas (like QListWidget) might not be correctly styled and can even become unusable.
If you plan to have a common background color for all widgets, it's better to set the Window role of the application palette:
app = QApplication(sys.argv)
palette = app.palette()
palette.setColor(palette.Window, QColor(150, 150, 150))
app.setPalette(palette)
# ...
In this way the current style will know exactly when to use that color as background, or as a component for other UI elements.
I am trying to override the paintEvent() of QMenu to make it have rounded corners.
The context menu should look something like this.
Here is the code I have tried But nothing appears:
from PyQt5 import QtWidgets, QtGui, QtCore
import sys
class Example(QtWidgets.QMainWindow):
def __init__(self):
super().__init__()
self.initUI()
def initUI(self):
self.setGeometry(300, 300, 300, 200)
self.setWindowTitle('Context menu')
self.show()
def contextMenuEvent(self, event):
cmenu = AddContextMenu(self)
newAct = cmenu.addAction("New")
openAct = cmenu.addAction("Open")
quitAct = cmenu.addAction("Quit")
action = cmenu.exec_(self.mapToGlobal(event.pos()))
class AddContextMenu(QtWidgets.QMenu):
def __init__(self, *args, **kwargs):
super(AddContextMenu, self).__init__()
self.painter = QtGui.QPainter(self)
self.setMinimumSize(150, 200)
self.pen = QtGui.QPen(QtCore.Qt.red)
#self.setStyleSheet('color:white; background:gray; border-radius:4px; border:2px solid white;')
def paintEvent(self, event) -> None:
self.pen.setWidth(2)
self.painter.setPen(self.pen)
self.painter.setBrush(QtGui.QBrush(QtCore.Qt.blue))
self.painter.drawRoundedRect(10, 10, 100, 100, 4.0, 4.0)
self.update()
#self.repaint()
#super(AddContextMenu, self).paintEvent(event)
def main():
app = QtWidgets.QApplication(sys.argv)
ex = Example()
sys.exit(app.exec_())
if __name__ == '__main__':
main()
Note: setting a style sheet doesn't work for me:
this is what I get when using the style sheet It isn't completely rounded.
This is the paintEvent after #musicamante suggestion(This is just for him/her to check)
def paintEvent(self, event) -> None:
painter = QtGui.QPainter(self)
#self.pen.setColor(QtCore.Qt.white)
#painter.setFont(QtGui.QFont("times", 22))
#painter.setPen(self.pen)
#painter.drawText(QtCore.QPointF(0, 0), 'Hello')
self.pen.setColor(QtCore.Qt.red)
painter.setPen(self.pen)
painter.setBrush(QtCore.Qt.gray)
painter.drawRoundedRect(self.rect(), 20.0, 20.0)
and in the init()
self.pen = QtGui.QPen(QtCore.Qt.red)
self.pen.setWidth(2)
I cannot comment on the paintEvent functionality, but it is possible to implement rounded corners using style-sheets. Some qmenu attributes have to be modified in order to disable the default rectangle in the background, which gave you the unwanted result.
Here is a modified version of your Example using style-sheets + custom flags (no frame + transparent background):
from PyQt5 import QtWidgets, QtCore
import sys
class Example(QtWidgets.QMainWindow):
def __init__(self):
super().__init__()
self.initUI()
def initUI(self):
self.setGeometry(300, 300, 300, 200)
self.setWindowTitle('Context menu')
self.show()
def contextMenuEvent(self, event):
cmenu = QtWidgets.QMenu()
# disable default frame and background
cmenu.setWindowFlags(QtCore.Qt.FramelessWindowHint)
cmenu.setAttribute(QtCore.Qt.WA_TranslucentBackground)
# set stylesheet, add some padding to avoid overlap of selection with rounded corner
cmenu.setStyleSheet("""
QMenu{
background-color: rgb(255, 255, 255);
border-radius: 20px;
}
QMenu::item {
background-color: transparent;
padding:3px 20px;
margin:5px 10px;
}
QMenu::item:selected { background-color: gray; }
""")
newAct = cmenu.addAction("New")
openAct = cmenu.addAction("Open")
quitAct = cmenu.addAction("Quit")
action = cmenu.exec_(self.mapToGlobal(event.pos()))
def main():
app = QtWidgets.QApplication(sys.argv)
ex = Example()
sys.exit(app.exec_())
if __name__ == '__main__':
main()
Setting the border radius in the stylesheet for a top level widget (a widget that has its own "window") is not enough.
While the solution proposed by Christian Karcher is fine, two important considerations are required:
The system must support compositing; while this is true for most modern OSes, at least on Linux there is the possibility that even an up-to-date system does not support it by choice (I disabled on my computer); if that's the case, setting the WA_TranslucentBackground attribute will not work.
The FramelessWindowHint should not be set on Linux, as it may lead to problems with the window manager, so it should be set only after ensuring that the OS requires it (Windows).
In light of that, using setMask() is the correct fix whenever compositing is not supported, and this has to happen within the resizeEvent(). Do note that masking is bitmap based, and antialiasing is not supported, so rounded borders are sometimes a bit ugly depending on the border radius.
Also, since you want custom colors, using stylesheets is mandatory, as custom painting of a QMenu is really hard to achieve.
class AddContextMenu(QtWidgets.QMenu):
def __init__(self, *args, **kwargs):
super(AddContextMenu, self).__init__()
self.setMinimumSize(150, 200)
self.radius = 4
self.setStyleSheet('''
QMenu {{
background: blue;
border: 2px solid red;
border-radius: {radius}px;
}}
QMenu::item {{
color: white;
}}
QMenu::item:selected {{
color: red;
}}
'''.format(radius=self.radius))
def resizeEvent(self, event):
path = QtGui.QPainterPath()
# the rectangle must be translated and adjusted by 1 pixel in order to
# correctly map the rounded shape
rect = QtCore.QRectF(self.rect()).adjusted(.5, .5, -1.5, -1.5)
path.addRoundedRect(rect, self.radius, self.radius)
# QRegion is bitmap based, so the returned QPolygonF (which uses float
# values must be transformed to an integer based QPolygon
region = QtGui.QRegion(path.toFillPolygon(QtGui.QTransform()).toPolygon())
self.setMask(region)
Some side notes about your paintEvent implementation, not necessary in this specific case for the above reason, but still important (some points are related to portions of code that have been commented, but the fact that you tried them makes worth mentioning those aspects):
The QPainter used for a widget must never be instanciated outside a paintEvent(): creating the instance in the __init__ as you did is a serious error and might even lead to crash. The painter can only be created when the paintEvent is received, and shall never be reused. This clearly makes useless to set it as an instance attribute (self.painter), since there's no actual reason to access it after the paint event.
If the pen width is always the same, then just set it in the constructor (self.pen = QtGui.QPen(QtCore.Qt.red, 2)), continuously setting it in the paintEvent is useless.
QPen and QBrush can directly accept Qt global colors, so there's no need to create a QBrush instance as the painter will automatically (internally and fastly) set it: self.painter.setBrush(QtCore.Qt.blue).
self.update() should never be called within a paintEvent (and not even self.repaint() should). The result in undefined and possibly dangerous.
If you do some manual painting with a QPainter and then call the super paintEvent, the result is most likely that everything painted before will be hidden; as a general rule, the base implementation should be called first, then any other custom painting should happen after (in this case it obviously won't work, as you'll be painting a filled rounded rect, making the menu items invisible).
I have implemented round corners menu using QListWidget and QWidget. You can download the code in https://github.com/zhiyiYo/PyQt-Fluent-Widgets/blob/master/examples/menu/demo.py.
I have the following code:
import datetime
from PyQt5 import QtCore
from PyQt5.QtWidgets import *
from PyQt5.QtGui import *
class RadioDelegate(QStyledItemDelegate):
def __init__(self, owner, chs):
super().__init__(owner)
self.items = chs
def paint(self, painter, option, index):
if isinstance(self.parent(), QAbstractItemView):
self.parent().openPersistentEditor(index)
super(RadioDelegate, self).paint(painter, option, index)
def createEditor(self, parent, option, index):
editor = QGroupBox(parent)
# editor.setMinimumHeight(38)
editor.setContentsMargins(0, 0, 0, 0)
layout = QHBoxLayout()
style = """
padding: 0px;
margin-top: 0px;
outline: none;
border: none
"""
for k in self.items:
rb = QRadioButton(k)
rb.setStyleSheet(style)
layout.addWidget(rb)
editor.setStyleSheet(style)
editor.setLayout(layout)
return editor
def setEditorData(self, editor, index):
value = index.data(QtCore.Qt.DisplayRole)
print("setEditorData-" + str(datetime.datetime.now()) + " " + str(value))
def setModelData(self, editor, model, index):
print("setModelData-" + str(datetime.datetime.now()))
def updateEditorGeometry(self, editor, option, index):
editor.setGeometry(option.rect)
class Window(QMainWindow):
def __init__(self):
super().__init__()
self.setGeometry(40, 80, 550, 250)
self.table = QTableView(self)
self.model = QStandardItemModel(self)
self.table.setModel(self.model)
self.table.setItemDelegateForColumn(3, RadioDelegate(self, ["abc", "xyz"]))
self.populate()
self.table.resizeRowsToContents()
self.table.clicked.connect(self.on_click)
self.setCentralWidget(self.table)
def on_click(self, event):
row = event.row()
column = event.column()
cell_dict = self.model.itemData(event)
cell_value = cell_dict.get(0)
print("Row {}, column {} clicked: {}".format(row, column, cell_value))
def populate(self):
values = [["1st", "2nd", "3rd", "radio"], ["111", "222", "333", ""], ["abc", "xyz", "123", ""]]
for value in values:
row = []
for item in value:
cell = QStandardItem(str(item))
row.append(cell)
self.model.appendRow(row)
if __name__ == '__main__':
import sys
app = QApplication(sys.argv)
ex = Window()
ex.show()
sys.exit(app.exec_())
And I get the look of the edited cell (4th column), which is not acceptable:
cell with radio buttons
How can I get rid of these margins or this padding? Or make the group box extend to cover the whole cell?
I have tried to manipulate style sheets, as you can see, but with no effect.
Of course, I can use "editor.setMinimumHeight(38)", but the top margin or whatever it is, still remains, and I can select the whole cell (not only the radio buttons).
By the way! Do you know how the paint method should look like to display radio buttons also in display mode?
You have to set the margins of the layout too:
def createEditor(self, parent, option, index):
editor = QGroupBox(parent)
# editor.setMinimumHeight(38)
editor.setContentsMargins(0, 0, 0, 0)
layout = QHBoxLayout()
layout.setContentsMargins(0, 0, 0, 0)
When a layout is created it has no margins, but they will change as soon as it is applied to a widget:
From the getContentsMargins() documentation:
By default, QLayout uses the values provided by the style.
These values are provided by the following QStyle's pixelmetrics: PM_LayoutLeftMargin, PM_LayoutTopMargin, PM_LayoutRightMargin, PM_LayoutBottomMargin. They all depend on the style internal parameters, the level of the widget (if it's a top-level widget - a "window" - or a child) and the dpi of the screen.
While newly created layouts usually return (0, 0, 0, 0) from getContentsMargins(), internally their value is actually -1. When the layout is applied to a widget, Qt will use the style's fallback values only if they've not been set to a value >= 0.
So, the margin has to be explicitly set even for 0 values to ensure that no margin actually exists; conversely, the style's default values can be restored by setting each margin to -1.
A couple of suggestions, even if they're not related to the actual question.
First of all, don't try to open the persistent editor within the paintEvent. Despite the fact that the method you used wouldn't work anyway, paintEvents happen very often, and creating a new widget inside a paintEvent would require another call to the paintEvent itself, potentially creating an infinite recursion (luckily, Qt would ignore the openPersistentEditor if it already exists, but that's not the point).
Whenever you're using a complex widget, it's better to set the background of the editor, otherwise the item's text might be visible under the editor in most styles. Also, there's no need for a QGroupBox for this situation, a simple QWidget will suffice.
With CSS you can just do this (there's no need to apply the style to the children):
editor.setStyleSheet("QWidget { background: palette(base); }")
If you don't need specific customization, you can avoid stylesheets at all and just set the autoFillBackground property:
editor.setAutoFillBackground(True)
Finally, the biggest issue of this kind of implementation is that if the column is not wide enough, some radio buttons might become invisible as using setGeometry will "clip" the contents to the item rectangle.
To avoid that, you can adjust the geometry rectangle to accomodate all its contents, based on its minimum size hint:
def updateEditorGeometry(self, editor, option, index):
rect = QtCore.QRect(option.rect)
minWidth = editor.minimumSizeHint().width()
if rect.width() < minWidth:
rect.setWidth(minWidth)
editor.setGeometry(rect)
Unfortunately, this will not be a very good thing if you want to show the editor even in "display mode", as you requested.
To do that in a good fashion while keeping a good user experience, things will be a bit more complex: the editor should be always be clipped when the item is not the current index, and automatically "resized" when some interaction is needed; also, if a hidden radio is selected, the user might click on an unchecked one, which will make that radio checked, without knowing the previous status.
To do so I'm using an event filter that will "clip" the widget using setMask() if it has no focus, and show all of it whenever it gets it. Since radio buttons can steal focus, I'm setting their focusPolicy to NoFocus. This also allows me to check if the widget has received a click to toggle radio buttons or just to start the editing; in that case, the click event will be ignored.
class RadioDelegate(QStyledItemDelegate):
def __init__(self, owner, chs):
super().__init__(owner)
self.items = chs
def createEditor(self, parent, option, index):
editor = QWidget(parent)
editor.setContentsMargins(0, 0, 0, 0)
editor.setAutoFillBackground(True)
# create a button group to keep track of the checked radio
editor.buttonGroup = QButtonGroup()
# adding the widget as an argument to the layout constructor automatically
# applies it to the widget
layout = QHBoxLayout(editor)
layout.setContentsMargins(0, 0, 0, 0)
for i, k in enumerate(self.items):
rb = QRadioButton(k)
layout.addWidget(rb)
# prevent the radio to get focus from keyboard or mouse
rb.setFocusPolicy(QtCore.Qt.NoFocus)
rb.installEventFilter(self)
editor.buttonGroup.addButton(rb, i)
# add a stretch to always align contents to the left
layout.addStretch(1)
# set a property that will be used for the mask
editor.setProperty('offMask', QRegion(editor.rect()))
editor.installEventFilter(self)
return editor
def eventFilter(self, source, event):
if event.type() == QtCore.QEvent.MouseButtonPress:
if isinstance(source, QRadioButton):
if not source.parent().hasFocus():
# the parent has no focus, set it and ignore the click
source.parent().setFocus()
return True
elif not source.hasFocus():
# the container has been clicked, check
source.setFocus()
elif event.type() == QtCore.QEvent.FocusIn:
# event received as a consequence of setFocus
# clear the mask to show it completely
source.clearMask()
elif event.type() == QtCore.QEvent.FocusOut:
# another widget has requested focus, set the mask
source.setMask(source.property('offMask'))
# update the table viewport to get rid of possible
# grid lines left after masking
source.parent().update()
return super().eventFilter(source, event)
def updateEditorGeometry(self, editor, option, index):
rect = QtCore.QRect(option.rect)
minWidth = editor.minimumSizeHint().width()
if rect.width() < minWidth:
rect.setWidth(minWidth)
editor.setGeometry(rect)
# create a new mask based on the option rectangle, then apply it
mask = QRegion(0, 0, option.rect.width(), option.rect.height())
editor.setProperty('offMask', mask)
editor.setMask(mask)
def setEditorData(self, editor, index):
value = index.data(QtCore.Qt.DisplayRole)
if value in self.items:
editor.buttonGroup.button(self.items.index(value)).setChecked(True)
def setModelData(self, editor, model, index):
button = editor.buttonGroup.checkedId()
if button >= 0:
model.setData(index, self.items[button], QtCore.Qt.DisplayRole)
class Window(QMainWindow):
def __init__(self):
# ...
# I just added an option for the purpose of this example
self.table.setItemDelegateForColumn(3, RadioDelegate(self, ["abc", "xyz", 'www']))
# ...
# open a persistent editor for all rows in column 4
for row in range(self.model.rowCount()):
self.table.openPersistentEditor(self.model.index(row, 3))