Blending text into background while using native font rendering

I'm trying to implement a plain text display widget, which fades-out into the background on both its sides.

Unfortunately the only way I've been able to achieve this fade-out effect while using the Windows' font engine is by overlaying a gradient going from a solid background color into transparency. This method works fine for when the background behind the widget is consistent, but this is not always the case (e.g. when placed into a QTabWidget it uses the Button role instead of the Window role, or anything non uniform) and causes the gradient's color to be mismatched

mismatched fade-out color

Here's an example of when I'm using the Window color role for the background but the actual background is using the Button color role

I have tried painting both into QImage and then painting it as a whole into the widget, and a QGraphicsOpacityEffect set on the widget, but both of these do not use the native Windows drawing and thus have degraded looks, which is highlighted on these images compared to the current method.

The first image highlights how it should look, with it being rendered using ClearType. On the second image, painting into a QImage is used which loses the subpixel anti-aliasing. The third image is using the QGraphicsOpacityEffect which causes the text to look even more blurry, and darker.

Native painting Painting into a QImage Using a QGraphicsOpacityEffect

The current overlaying is done by painting simple gradient images over the text like so:

    def paint_event(self, paint_event: QtGui.QPaintEvent) -> None:
    """Paint the text at its current scroll position with the fade-out gradients on both sides."""
    text_y = (self.height() - self._text_size.height()) // 2

    painter = QtGui.QPainter(self)

    painter.set_clip_rect(
        QtCore.QRect(
            QtCore.QPoint(0, text_y),
            self._text_size,
        )
    )

    painter.draw_static_text(
        QtCore.QPointF(-self._scroll_pos, text_y),
        self._static_text,
    )
    # Show more transparent half of gradient immediately to prevent text from appearing cut-off.
    if self._scroll_pos == 0:
        fade_in_width = 0
    else:
        fade_in_width = min(
            self._scroll_pos + self.fade_width // 2, self.fade_width
        )

    painter.draw_image(
        -self.fade_width + fade_in_width,
        text_y,
        self._fade_in_image,
    )

    fade_out_width = self._text_size.width() - self.width() - self._scroll_pos
    if fade_out_width > 0:
        fade_out_width = min(self.fade_width, fade_out_width + self.fade_width // 2)

    painter.draw_image(
        self.width() - fade_out_width,
        text_y,
        self._fade_out_image,
    )

And the whole widget code can be found at https://github.com/Numerlor/Auto_Neutron/blob/3c1bdb8211411e86846710cceec9dc2b23b91cc6/auto_neutron/windows/gui/plain_text_scroller.py#L16


Solution 1:

As far as I know, and at least on Linux, I sincerely doubt that that would be possible, as "blending" the background would require knowing the (possibly cumulative) background of the parent(s), and subpixel rendering is not available whenever the background and/or foreground have alpha value below 1.0 (or 255) for raster drawing.

Also, text rendering with subpixel correction requires a surface that is aware of the screen, which makes drawing on image pointless.

If you're fine with the default text antialiasing, though, there's a much simpler approach to achieve the fading, and there's no need to override the painting, as you can achieve this with a basic QLabel and using a QLinearGradient set for the WindowText palette role.

The trick is to use the minimumSizeHint() to get the optimal width for the text and compute the correct stops of the gradient, since those values are always in the range between 0 and 1.

class FaderLabel(QtWidgets.QLabel):
    fadeWidth = 20
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        palette = self.palette()
        self.baseColor = palette.color(palette.WindowText)
        self.fadeColor = QtGui.QColor(self.baseColor)
        self.fadeColor.setAlpha(0)
        self.grad = QtGui.QLinearGradient(0, 0, 1, 0)
        self.grad.setCoordinateMode(self.grad.ObjectBoundingMode)
        self.setMinimumWidth(self.fadeWidth * 2)

    def updateColor(self):
        fadeRatio = self.fadeWidth / self.minimumSizeHint().width()
        self.grad.setStops([
            (0, self.fadeColor), 
            (fadeRatio, self.baseColor), 
            (1 - fadeRatio, self.baseColor), 
            (1, self.fadeColor)
        ])
        palette = self.palette()
        palette.setBrush(palette.WindowText, QtGui.QBrush(self.grad))
        self.setPalette(palette)

    def setText(self, text):
        super().setText(text)
        self.updateColor()

    def resizeEvent(self, event):
        super().resizeEvent(event)
        self.updateColor()


app = QtWidgets.QApplication([])
p = app.palette()
p.setColor(p.Window, QtCore.Qt.black)
p.setColor(p.WindowText, QtCore.Qt.white)
app.setPalette(p)
test = FaderLabel('Hello, I am fading label')
test.show()
app.exec()

The subpixel rendering (like ClearType) will be not be available as written above, since using a gradient makes it almost impossible for the engine to properly draw the "mid" pixels.

Another problem with the above code is that it won't work when using stylesheets. In that case, the solution is to create a helper function that will set the existing stylesheet (including the inherited one), get the actual text color, then create a custom stylesheet with the gradient and finally apply that.

class FaderLabel2(QtWidgets.QLabel):
    fadeWidth = 20
    _styleSheet = ''
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.updateTimer = QtCore.QTimer(
            singleShot=True, interval=1, timeout=self.updateColor)

    def updateColor(self):
        # restore the default stylesheet (if any)
        super().setStyleSheet(self._styleSheet)
        # ensure that the palette is properly updated
        self.ensurePolished()
        baseColor = self.palette().color(QtGui.QPalette.WindowText)
        fadeColor = QtGui.QColor(baseColor)
        fadeColor.setAlpha(0)
        fadeRange = self.fadeWidth / self.minimumSizeHint().width()
        styleSheet = '''
            color: qlineargradient(x1:0, y1:0, x2:1, y2:0,
                stop:0 {fade}, 
                stop:{start} {full}, 
                stop:{end} {full},
                stop:1 {fade});
        '''.format(
            fade=fadeColor.name(QtGui.QColor.HexArgb), 
            full=baseColor.name(QtGui.QColor.HexArgb), 
            start=fadeRange, end=1-fadeRange)
        super().setStyleSheet(styleSheet)

    def changeEvent(self, event):
        if event.type() == event.StyleChange:
            self.updateTimer.start()

    def setText(self, text):
        super().setText(text)
        self.updateColor()

    def setStyleSheet(self, styleSheet):
        self._styleSheet = styleSheet
        self.updateTimer.start()

    def resizeEvent(self, event):
        super().resizeEvent(event)
        self.updateTimer.start()