Search code examples
pythonqtuser-interfacepyqtpyside

How to fix jagged edges when drawing custom shaped translucent QWidget window?


I am trying to make a QWidget with a triangular tip at the bottom with the help of this tutorial. While I am able to get the shape I want, the tip has some jagged edges which I am not sure how to fix?

Image with an arrow pointing to a jagged border

I was initially setting a bitmap mask and had the same issue. That's when I got to know its better I paint the shape myself on a translucent window because of the anti-aliasing that comes with it but as you can see, still no luck.

I am not sure if I am doing something wrong or its because the shape is too small? If the scale was an issue, it should have been apparent in the button and text as well which seem smooth.

import typing as _t

# You might need to change this import to PySide2/PyQt5 depending on what you have.
from Qt import (
    QtCore as _QtCore,
    QtGui as _QtGui,
    QtWidgets as _QtWidgets,
)


class _TipTriangleDimensions(_t.NamedTuple):
    height: int = 15
    base: int = 10


class _TipTriangleCords(_t.NamedTuple):
    origin: _QtCore.QPoint
    tip: _QtCore.QPoint
    base: _QtCore.QPoint


class QBalloonTip(_QtWidgets.QWidget):
    def __init__(
        self, message: str, parent: _t.Optional[_QtWidgets.QWidget] = None
    ) -> None:
        super().__init__(
            parent=parent,
            f=_QtCore.Qt.FramelessWindowHint | _QtCore.Qt.WindowSystemMenuHint,
        )
        self.setAttribute(_QtCore.Qt.WA_TranslucentBackground)
        main_layout = _QtWidgets.QVBoxLayout()
        self._setup_ui(main_layout)
        self._message_label.setText(message)
        self._mask_region: _QtGui.QRegion = self._get_mask_region(
            _TipTriangleDimensions()
        )

    def paintEvent(self, event):
        painter = _QtGui.QPainter(self)
        painter.setRenderHint(_QtGui.QPainter.RenderHint.Antialiasing)
        painter_path = _QtGui.QPainterPath()
        painter_path.addRegion(self._mask_region)
        brush = _QtGui.QBrush(_QtCore.Qt.white)
        painter.fillPath(painter_path, brush)

    def _get_mask_region(self, triangle_d: _TipTriangleDimensions) -> _QtGui.QRegion:
        self.setContentsMargins(0, 0, 0, triangle_d.height)
        self.updateGeometry()
        self_sz: _QtCore.QSize = self.sizeHint()

        triangle_cords = self._get_triangle_shape_points(self_sz, triangle_d)

        origin = _QtCore.QPoint(0, 0)
        right = _QtCore.QPoint(self_sz.width(), triangle_cords.base.y())
        top = _QtCore.QPoint(self_sz.width(), origin.y())

        return _QtGui.QRegion(
            [
                origin,
                triangle_cords.tip,
                triangle_cords.base,
                right,
                top,
                origin,
            ]
        )

    @staticmethod
    def _get_triangle_shape_points(
        total_size: _QtCore.QSize, dimensions: _TipTriangleDimensions
    ) -> _TipTriangleCords:
        origin = _QtCore.QPoint(0, total_size.height() - dimensions.height)
        tip = _QtCore.QPoint(0, total_size.height())
        base = _QtCore.QPoint(dimensions.base, origin.y())

        return _TipTriangleCords(origin, tip, base)

    def _setup_ui(self, main_layout: _QtWidgets.QVBoxLayout):
        self._message_label = _QtWidgets.QLabel(parent=self)
        self._message_label.setFixedWidth(200)
        self._message_label.setFixedHeight(100)
        self._message_label.setWordWrap(True)

        main_layout.addWidget(self._message_label)
        main_layout.addWidget(_QtWidgets.QPushButton("Test"))
        self.setLayout(main_layout)


if __name__ == "__main__":
    app = _QtWidgets.QApplication([])
    widget = QBalloonTip("test message")
    widget.show()
    app.exec_()

Solution

  • tl;dr

    Only use QRegion when drawing orthogonal shapes (rectangles), otherwise opt for QPainterPath or QPainter functions for primitive shapes, like drawPolygon().

    Explanation

    The QRegion documentation clearly warns about its usage:

    This class is not suitable for constructing shapes for rendering, especially as outlines. Use QPainterPath to create paths and shapes for use with QPainter.

    The reason is that QRegion is completely pixel based, using 1-bit depth data: each pixel can just be drawn or not (see aliasing).

    Pixel based (alised) data and representation in imaging

    QRegion is conceptually identical to QBitmap, which can also be used as a QRegion constructor argument due to its close relation.

    A 1-bit depth pixmap like QBitmap is a simple pixmap (pixel map) storing all pixels, each one having a value of 0 or 1; a fully opaque QBitmap of 10x10 actually contains 100 bits, all set to 1 (or 0, depending on the format).

    The difference between QBitmap and QRegion is that the latter uses a more logical approach, grouping "drawn" (set) pixels in rectangles: a fully opaque 10x10 QRegion only contains one 10x10 rectangle, which is theoretically better than storing the whole pixel data. A 100x100 QBitmap occupies 1250 Bytes in memory (10000 bits / 8; in reality it requires more, but that's another story), but a fully opaque QRegion needs much less: the four x, y, width and height values of the full rectangle.

    If the region is more complex, it actually is a composition of rectangles, which is the reason for which the QRegion documentation warns about using very complex regions when drawing: the painter needs to draw each single rectangle the region is made of.

    A QRegion with a triangle having points (0, 0), (10, 0) and (0, 10) actually contains 10 rectangles: each one is one pixel tall, with the first being 10 pixels wide, and the last only 1:

    A QRegion triangle

    A 10x zoomed image of the given region.

    When adding that QRegion to a QPainterPath, you will get a similar result: a path containing 10 adjacent rectangles.
    You may "simplify" (see QPainterPath.simplified()) the path in order to get a single (or many) polygons joining adjacent areas, but it's just a logical simplification, the drawn result would be identical.

    Note that with diagonal lines having 45° based angles, the effect is not that bad; here is an example of a (0, 0), (100, 0), (0, 100) triangle:

    Seemingly smooth triangle

    This happens due to a slight optical illusion for which the vicinity and consistency of pixels results in a pseudo-smooth line. Watch what happens if the angle is different, though, for instance by setting the second point at (75, 0):

    Not so smooth triangle

    This "jigsaw" effect, similar to what you see in your case, is caused by the less consistent pixel difference, which is clear by zooming in:

    Zoomed in aliased triangle

    A similar result would be obtained by drawing paths or shapes with QPainter without setting the Antialiasing render hint.

    Reasonable QRegion use cases

    There are actually few cases for which using QRegion makes sense:

    • when trying to optimize painting, so that only a portion of a widget is redrawn; this is common in Qt item views, so they don't need to repaint all their contents every time: item views can have hundreds of visible items at any time, each one requiring lots of underlying computations for its painting, and therefore they will eventually compute and redraw only the items that collide with the QPaintEvent.region() of the paintEvent(), completely skipping the others;
    • when setting a widget mask, which allows making the shape of a widget (typically, a window) different than the common orthogonal rectangle, like displaying windows with rounded corners (as the default theme in Windows XP);

    There obviously are cases for which it may make sense to use QRegion for rendering, and even for using it within a QPainterPath (eg. to simplify polygon matching when joining/subtracting regions), but, for general drawing purposes, it's obviously not appropriate.

    Further notes

    The QRegion constructor you're using (with a list of QPoints) is somehow accepted by PySide, but it's not really compliant with the standard documentation. The more appropriate syntax would have been QRegion(QPolygon([... list of points ...])).

    The QWidget.setLayout() call is effective only as long as no layout has already been set on the widget, therefore creating a layout and calling _setup_ui with it is a bit pointless; if you really want to use a separate function to create the UI, just create the layout there; you can even create the layout with the widget in its constructor, therefore making setLayout() unnecessary.

    Always put efforts in optimizing whatever happens within a paintEvent() call, as it should always return as soon as possible. While the above explanation makes it unnecessary to create the region, consider the flaws in your code: creating a path and adding a known region for each paintEvent() call (which can happen a lot and very frequently) is inefficient; if you want to store/cache something, just do it with the QPainterPath instead.

    Calling setFixedWidth() followed by setFixedHeight() is inefficient, for code logic, Qt implementation, and, last but not least, code readability: just call setFixedSize().
    Also remember that both PyQt and PySide are bindings around the Qt framework, meaning that each access to a "Qt object" (including function calls, which may require object conversion of function arguments) passes through the Python bottleneck: it may not be that relevant for your case, but in a situation with tens, hundreds or even thousands of widgets using the same functions, calling two bound functions instead of one can make a lot of difference.