2

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)

Jambon
  • 115
  • 10
  • That's strange. I'm quite sure that `resizeEvent()` is called (it always is for all widgets when they're shown the first time), so I'm under the impression that what's not called is the `styleHint()` *of the proxy*, which usually happens when a widget has a stylesheet applied: in that case, the Qt sets a custom private QStyleSheetStyle as a style for the widget, which completely ignores custom style overrides (except for a few functions). Can you please try if `styleHint()` gets called in the following cases? 1. add `app.setStyle('fusion')` before `setStyleSheet()`; 2. remove the stylesheet. – musicamante May 04 '23 at 17:43
  • I don't know how I can debug to see if `styleHint()` is called when setting the style to `'fusion'` since it would be in the compiled code of Qt. Anyway I just noticed that I made a mistake while debugging. Actually, with and without the stylesheet, the `sizeHint` of `ProxyStyle` is actually called with the 76 hint, but never goes further than the first `if` ; it always returns `True` without getting into the logic producing the mask. I'm editting my question. – Jambon May 05 '23 at 11:39
  • Sorry, I meant `app.setStyle(ProxyStyle('fusion'))`. In any case, what do you mean with "the first if"? When it checks the `super()` call? In that case, it means that the current style actually creates a mask on its own. I don't know what system you're using, and your image has not a lot of contrast (maybe try with a darker background), but if you want to completely override that, just remove that block. – musicamante May 05 '23 at 16:35
  • About the first `if` I meant `if super().styleHint(hint, opt, widget, returnData):`, yes. I am running Windows 11, and it might be the issue ; by default the tooltips don't have the corners drawn. It seems that Qt considers the tooltips to be Ellipses by default on W11, since `QtGui.QRegion(QtGui.QBitmap.fromImage(src.createHeuristicMask()))` returns an Ellipse QRegion, which means that the block `if mask == ...` was never entered. Removing that condition and the first block made everything work correctly. Thanks for your input, I'll self answer with my final working code. – Jambon May 09 '23 at 11:56

1 Answers1

3

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()
Jambon
  • 115
  • 10
  • 1
    But if you remove the `if mask == QRegion():` block, it will completely ignore the border radius of the style sheet. The whole point of that check is to respect the style sheet, because the heuristic mask is based on the border radius, and it *is* correct that the `if` usually returns `False`: it's there as a fall back to provide a basic rounded border for all tool tips, even when it's not set for the application (or it's overridden by the widget stylesheet). – musicamante May 09 '23 at 13:21
  • Right, however the line `widget.render(src)` creates an infinite loop, because the `render` calls back the method `styleHint` with an `SH_ToolTip_Mask`, which then calls once more `widget.render(src)`. I don't have yet an idea about how to fix this issue. I'd be happy to edit the answer for a better result ! – Jambon May 09 '23 at 13:41
  • 1
    Mh, it's possible that the style requires the widget to adjust its size when preparing to render (painting doesn't need the mask from the style hint, but `resizeEvent()` will call it again). As a workaround, you could set a recursive check with a basic bool flag that you can check in the `if`; add a basic `_recursiveCheck = False` as class attribute, then change to `if hint == self.SH_ToolTip_Mask and widget and not self._recursiveCheck:`, add `self._recursiveCheck = True` right after that and set it again to `False` before returning. – musicamante May 09 '23 at 14:01
  • Thanks, I was able to get the code to work correctly that way. I editted the answer to get it to the last version of the working code. – Jambon May 09 '23 at 15:21
  • 1
    Ok. Note that now the `if mask` check becomes almost pointless if used in this way, since you're resizing and translating the mask. That check should be put before that process, otherwise it would be useless. Also, consider upvoting the original answer, since that's where most code comes from (for future reference, also consider to add your answers to the original post if they are just "fixes" -like in this case-, as we generally try to avoid duplicates). – musicamante May 09 '23 at 15:50