pythonpyqt5qt5linux-mintcinnamon

PyQt5 Linux Mint Cinnamon native 'delete', 'apply' buttons


I'm trying to create a pyqt5 application on Linux mint 19 (cinnamon). I have a 'delete' and 'apply' buttons, but i want to give them more relevant look. Like on this pictures:

native Linux mint buttons

more

It is a native look of buttons with apply or delete role on linux mint and I want to create buttons like this in my application, but I haven't found a way to do so.

It seems that windows and mac have qtmacextras and qtwinextras modules for such stuff. Linux have some kind of qtx11extras, but that module does not provide such functionality.


Solution

  • While the solution already provided works perfectly, is not completely application wide, especially if you want to control the appearance of buttons of dialogs.

    Even if you apply the stylesheet to the application, you'll need to ensure to correctly set the selectors in parents: in the other answer, for example, setting the background property without selectors will clear the app stylesheet inheritance of the children.

    Assuming the stylesheet is set to the application and all other stylesheets set are carefully written, the problem comes with dialog windows.

    If the dialog is created manually, alternate colors can be set by specifying the stylesheet for each button for which you want the alternate color, but this is impossible for those created with static functions.

    In that case, the only possibility is to use a QProxyStyle. The following is a possible implementation that also allows to set custom colors and fonts, and automatically sets an alternate color for "negative" roles of dialog button boxes (cancel, ignore, etc).

    In this example I just applied the style to the application, but the message box is created using the information() static function. The "Alternate" button is manually set using a custom property: button.setProperty('alternateColor', True).

    Cool colored buttons!

    class ColorButtonStyle(QtWidgets.QProxyStyle):
        def __init__(self, *args, **kwargs):
            if isinstance(kwargs.get('buttonFont'), QtGui.QFont):
                self._buttonFont = kwargs.pop('buttonFont')
            else:
                self._buttonFont = QtWidgets.QApplication.font()
                self._buttonFont.setPointSize(20)
            super().__init__(*args, **kwargs)
            self._buttonFontMetrics = QtGui.QFontMetrics(self._buttonFont)
            self._defaultButtonColor = QtGui.QColor(109, 180, 66)
            self._defaultTextColor = QtGui.QColor(QtCore.Qt.white)
            self._alternateButtonColor = QtGui.QColor(240, 74, 80)
            self._alternateTextColor = None
            self._alternateRoles = set((
                QtWidgets.QDialogButtonBox.RejectRole, 
                QtWidgets.QDialogButtonBox.DestructiveRole, 
                QtWidgets.QDialogButtonBox.NoRole, 
            ))
    
        def _polishApp(self):
            self.polish(QtWidgets.QApplication.instance())
    
        def buttonFont(self):
            return QtGui.QFont(self._buttonFont)
    
        @QtCore.pyqtSlot(QtGui.QFont)
        def setButtonFont(self, font):
            if not isinstance(font, QtGui.QFont) or font == self._buttonFont:
                return
            self._buttonFont = font
            self._buttonFontMetrics = QtGui.QFontMetrics(self._buttonFont)
            self._polishApp()
    
        def defaultButtonColor(self):
            return QtGui.QColor(self._defaultButtonColor)
    
        @QtCore.pyqtSlot(QtCore.Qt.GlobalColor)
        @QtCore.pyqtSlot(QtGui.QColor)
        def setDefaultButtonColor(self, color):
            if isinstance(color, QtCore.Qt.GlobalColor):
                color = QtGui.QColor(color)
            elif not isinstance(color, QtGui.QColor):
                return
            self._defaultButtonColor = color
            self._polishApp()
    
        def alternateButtonColor(self):
            return QtGui.QColor(self._alternateButtonColor)
    
        @QtCore.pyqtSlot(QtCore.Qt.GlobalColor)
        @QtCore.pyqtSlot(QtGui.QColor)
        def setAlternateButtonColor(self, color):
            if isinstance(color, QtCore.Qt.GlobalColor):
                color = QtGui.QColor(color)
            elif not isinstance(color, QtGui.QColor):
                return
            self._alternateButtonColor = color
            self._polishApp()
    
        def alternateRoles(self):
            return self._alternateRoles
    
        def setAlternateRoles(self, roles):
            newRoles = set()
            for role in roles:
                if isinstance(role, QtWidgets.QDialogButtonBox.ButtonRole):
                    newRoles.add(role)
            if newRoles != self._alternateRoles:
                self._alternateRoles = newRoles
                self._polishApp()
    
        def setAlternateRole(self, role, activate=True):
            if isinstance(role, QtWidgets.QDialogButtonBox.ButtonRole):
                if activate and role in self._alternateRoles:
                    self._alternateRoles.add(role)
                    self._polishApp()
                elif not activate and role not in self._alternateRoles:
                    self._alternateRoles.remove(role)
                    self._polishApp()
    
        def defaultTextColor(self):
            return QtGui.QColor(self._defaultTextColor)
    
        @QtCore.pyqtSlot(QtCore.Qt.GlobalColor)
        @QtCore.pyqtSlot(QtGui.QColor)
        def setDefaultTextColor(self, color):
            if isinstance(color, QtCore.Qt.GlobalColor):
                color = QtGui.QColor(color)
            elif not isinstance(color, QtGui.QColor):
                return
            self._defaultTextColor = color
            self._polishApp()
    
        def alternateTextColor(self):
            return QtGui.QColor(self._alternateTextColor or self._defaultTextColor)
    
        @QtCore.pyqtSlot(QtCore.Qt.GlobalColor)
        @QtCore.pyqtSlot(QtGui.QColor)
        def setAlternateTextColor(self, color):
            if isinstance(color, QtCore.Qt.GlobalColor):
                color = QtGui.QColor(color)
            elif not isinstance(color, QtGui.QColor):
                return
            self._alternateTextColor = color
            self._polishApp()
    
        def drawControl(self, element, option, painter, widget):
            if element == self.CE_PushButton:
                isAlternate = False
                if widget and isinstance(widget.parent(), QtWidgets.QDialogButtonBox):
                    role = widget.parent().buttonRole(widget)
                    if role in self._alternateRoles:
                        isAlternate = True
                elif widget.property('alternateColor'):
                    isAlternate = True
    
                if isAlternate:
                    color = self.alternateButtonColor()
                    textColor = self.alternateTextColor()
                else:
                    color = self.defaultButtonColor()
                    textColor = self.defaultTextColor()
    
                if not option.state & self.State_Enabled:
                    color.setAlpha(color.alpha() * .75)
                    textColor.setAlpha(textColor.alpha() * .75)
    
                # switch the existing palette with a new one created from it;
                # this shouldn't be necessary, but better safe than sorry
                oldPalette = option.palette
                palette = QtGui.QPalette(oldPalette)
                palette.setColor(palette.ButtonText, textColor)
                # some styles use WindowText for flat buttons
                palette.setColor(palette.WindowText, textColor)
                option.palette = palette
    
                # colors that are almost black are not very affected by "lighter"
                if color.value() < 32:
                    lightColor = QtGui.QColor(48, 48, 48, color.alpha())
                else:
                    lightColor = color.lighter(115)
                darkColor = color.darker()
                if option.state & self.State_MouseOver:
                    # colors that are almost black are not very affected by "lighter"
                    bgColor = lightColor
                    lighterColor = lightColor.lighter(115)
                    darkerColor = darkColor.darker(115)
                else:
                    bgColor = color
                    lighterColor = lightColor
                    darkerColor = darkColor
                if option.state & self.State_Raised and not option.state & self.State_On:
                    topLeftPen = QtGui.QPen(lighterColor)
                    bottomRightPen = QtGui.QPen(darkerColor)
                elif option.state & (self.State_On | self.State_Sunken):
                    if option.state & self.State_On:
                        bgColor = bgColor.darker()
                    else:
                        bgColor = bgColor.darker(125)
                    topLeftPen = QtGui.QPen(darkColor)
                    bottomRightPen = QtGui.QPen(lighterColor)
                else:
                    topLeftPen = bottomRightPen = QtGui.QPen(bgColor)
    
                painter.save()
                painter.setRenderHints(painter.Antialiasing)
                painter.translate(.5, .5)
                rect = option.rect.adjusted(0, 0, -1, -1)
                painter.setBrush(bgColor)
                painter.setPen(QtCore.Qt.NoPen)
                painter.drawRoundedRect(rect, 2, 2)
    
                if topLeftPen != bottomRightPen:
                    roundRect = QtCore.QRectF(0, 0, 4, 4)
                    painter.setBrush(QtCore.Qt.NoBrush)
    
                    # the top and left borders
                    tlPath = QtGui.QPainterPath()
                    tlPath.arcMoveTo(roundRect.translated(0, rect.height() - 4), 225)
                    tlPath.arcTo(roundRect.translated(0, rect.height() - 4), 225, -45)
                    tlPath.arcTo(roundRect, 180, -90)
                    tlPath.arcTo(roundRect.translated(rect.width() - 4, 0), 90, -45)
                    painter.setPen(topLeftPen)
                    painter.drawPath(tlPath)
    
                    # the bottom and right borders
                    brPath = QtGui.QPainterPath(tlPath.currentPosition())
                    brPath.arcTo(roundRect.translated(rect.width() - 4, 0), 45, -45)
                    brPath.arcTo(
                        roundRect.translated(rect.width() - 4, rect.height() - 4), 0, -90)
                    brPath.arcTo(
                        roundRect.translated(0, rect.height() - 4), 270, -45)
                    painter.setPen(bottomRightPen)
                    painter.drawPath(brPath)
    
    
                if option.state & self.State_HasFocus:
                    focusColor = QtGui.QColor(textColor).darker()
                    focusColor.setAlpha(focusColor.alpha() * .75)
                    painter.setPen(focusColor)
                    painter.setBrush(QtCore.Qt.NoBrush)
                    painter.drawRoundedRect(rect.adjusted(2, 2, -2, -2), 2, 2)
    
                painter.setFont(self._buttonFont)
                oldMetrics = option.fontMetrics
                option.fontMetrics = self._buttonFontMetrics
                self.drawControl(self.CE_PushButtonLabel, option, painter, widget)
                painter.restore()
    
                # restore the original font metrics and palette
                option.fontMetrics = oldMetrics
                option.palette = oldPalette
                return
    
            super().drawControl(element, option, painter, widget)
    
        def sizeFromContents(self, contentsType, option, size, widget=None):
            if contentsType == self.CT_PushButton:
                if option.text:
                    textSize = option.fontMetrics.size(
                        QtCore.Qt.TextShowMnemonic, option.text)
                    baseWidth = size.width() - textSize.width()
                    baseHeight = size.height() - textSize.height()
                    text = option.text
                else:
                    baseWidth = size.width()
                    baseHeight = size.height()
                    text = 'XXXX' if not option.icon else ''
                buttonTextSize = self._buttonFontMetrics.size(
                    QtCore.Qt.TextShowMnemonic, text)
                if not widget or widget.font() != QtWidgets.QApplication.font():
                    buttonTextSize = buttonTextSize.expandedTo(
                        QtWidgets.QApplication.fontMetrics().size(
                            QtCore.Qt.TextShowMnemonic, text))
                margin = self.pixelMetric(self.PM_ButtonMargin, option, widget)
                newSize = QtCore.QSize(
                    buttonTextSize.width() + baseWidth + margin * 2, 
                    buttonTextSize.height() + baseHeight + margin)
                return newSize.expandedTo(
                    super().sizeFromContents(contentsType, option, size, widget))
            return super().sizeFromContents(contentsType, option, size, widget)
    
    
    app = QtWidgets.QApplication(sys.argv)
    app.setStyle(ColorButtonStyle())
    # ...
    

    Starting from this you could also add other properties to better control the style, such as the radius of the rounded rectangles (just replace every "4" with your variable in the border drawing, and draw the background using half of it):

            painter.drawRoundedRect(rect, self._radius / 2, self._radius / 2)
    
            if topLeftPen != bottomRightPen:
                roundRect = QtCore.QRectF(0, 0, self._radius, self._radius)
                painter.setBrush(QtCore.Qt.NoBrush)
    
                # the top and left borders
                tlPath = QtGui.QPainterPath()
                tlPath.arcMoveTo(roundRect.translated(
                    0, rect.height() - self._radius), 225)
                tlPath.arcTo(roundRect.translated(
                       0, rect.height() - self._radius), 225, -45)