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
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.
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()