Search code examples
pythoncsspython-3.xpyqt5

Correctly displaying tooltips with rounded corners with PyQt5


I need to properly display a tooltip with rounded corners using PyQt5. The default behavior whenever using the border-radius: 8px; in the stylesheet of a QToolTip keeps the tooltip rendered as a rectangle, and the border-radius property only impacts the shape of the border, which is rather ugly.

This question is following this question : How to make Qtooltip corner round in pyqt5 which was left partially unanswered. Based on this answer, I tried this code :

from PyQt5 import QtCore, QtGui, QtWidgets
import sip

class ProxyStyle(QtWidgets.QProxyStyle):
    def styleHint(self, hint, opt=None, widget=None, returnData=None):
        if hint == self.SH_ToolTip_Mask and widget:
            if super().styleHint(hint, opt, widget, returnData):
                # the style already creates a mask
                return True
            returnData = sip.cast(returnData, QtWidgets.QStyleHintReturnMask)
            src = QtGui.QImage(widget.size(), QtGui.QImage.Format_ARGB32)
            src.fill(QtCore.Qt.transparent)
            widget.render(src)

            mask = QtGui.QRegion(QtGui.QBitmap.fromImage(
                src.createHeuristicMask()))
            if mask == QtGui.QRegion(opt.rect.adjusted(1, 1, -1, -1)):
                # if the stylesheet doesn't set a border radius, create one
                x, y, w, h = opt.rect.getRect()
                mask = QtGui.QRegion(x + 4, y, w - 8, h)
                mask += QtGui.QRegion(x, y + 4, w, h - 8)
                mask += QtGui.QRegion(x + 2, y + 1, w - 4, h - 2)
                mask += QtGui.QRegion(x + 1, y + 2, w - 2, h - 4)

            returnData.region = mask
            return 1
        return super().styleHint(hint, opt, widget, returnData)


app = QtWidgets.QApplication([])
app.setStyle(ProxyStyle())
palette = app.palette()
app.setStyleSheet('''
    QToolTip {
        color: black;
        background: white;
        border: 1px solid black;
        border-radius: 8px;
    }
''')
test = QtWidgets.QPushButton('Hover me', toolTip='Tool tip')
test.show()
app.exec()

However this does not work as expected : rendered tooltip the produced tooltip does not change from the default one.

After some debugging, I noticed that sizeHint() logic is actually never executed ; super().styleHint(hint, opt, widget, returnData) always returns True.

How can I render a tooltip with rounded corners ?

Edit : I am running Windows 11, and it seems that it is important (see answer)


Solution

  • It seems that by default, Windows 11 creates a mask for tooltips ; therefore if super().styleHint(hint, opt, widget, returnData): is always True. If we want to run the logic generating the mask, we have to remove that block.

    In order to render the tooltip, to get a mask to clip it later, we also have to prevent infinite recursion ; widget.render would call this styleHint which would in turn call widget.render. Therefore we have to add a recursive check.

    Lastly, due to the antialiased pixels of the border, the produced mask is a tiny bit too large. To fix that issue, we have to crop the mask of 1px in every direction, which can be done by downscaling the mask by 2 pixels in both directions and then translate the QRegion by 1px.

    Here is a working code (at least on Windows 11, with PyQt5):

    class ProxyStyle(QtWidgets.QProxyStyle):
        _recursive_check = False
    
        def styleHint(self, hint, opt=None, widget=None, returnData=None):
            if hint == self.SH_ToolTip_Mask and widget and not self._recursive_check:
                self._recursive_check = True
                returnData = sip.cast(returnData, QtWidgets.QStyleHintReturnMask)
    
                src = QtGui.QImage(widget.size(), QtGui.QImage.Format_ARGB32)
                src.fill(QtCore.Qt.transparent)
                widget.render(src)
                image = src.createHeuristicMask()
                bitmap = QtGui.QBitmap.fromImage(image)
                mask = QtGui.QRegion(bitmap)
                x, y, w, h = opt.rect.getRect()
                if mask == QtGui.QRegion(opt.rect.adjusted(1, 1, -1, -1)):
                    mask = QtGui.QRegion(x + 4, y, w - 8, h)
                    mask += QtGui.QRegion(x, y + 4, w, h - 8)
                    mask += QtGui.QRegion(x + 2, y + 1, w - 4, h - 2)
                    mask += QtGui.QRegion(x + 1, y + 2, w - 2, h - 4)
    
                image = image.scaled(image.width()-2, image.height()-2)
                bitmap = QtGui.QBitmap.fromImage(image)
                mask = QtGui.QRegion(bitmap).translated(1, 1)
    
                returnData.region = mask
                self._recursive_check = False
                return 1
    
            return super().styleHint(hint, option=opt, widget=widget, returnData=returnData)
    
    
    app = QtWidgets.QApplication([])
    app.setStyle(ProxyStyle())
    palette = app.palette()
    app.setStyleSheet('''
        QToolTip {{
            color: {fgd};
            background: {bgd};
            border: 2px solid black;
            border-radius: 13px;
        }}
    '''.format(
        fgd=palette.color(palette.ToolTipText).name(),
        bgd=palette.color(palette.ToolTipBase).name(),
        border=palette.color(palette.ToolTipBase).darker(110).name()
    ))
    
    test = QtWidgets.QPushButton('Hover me', toolTip='Tool tip')
    test.show()
    app.exec()