Wednesday, April 6, 2022

[SOLVED] PyQt5 Linux Mint Cinnamon native 'delete', 'apply' buttons

Issue

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)


Answered By - musicamante
Answer Checked By - David Goodson (WPSolving Volunteer)