Blend text into background when paiting with the widget as a paint device
-
I've been trying to find a solution to this for a couple of days, I'm trying to paint some text, and then blend it into its background at the edges.
Initially I did this through painting everything into aQImage
and the painting that image, which looked nice with the blending, but caused the text to not be rendered with ClearType on Windows, resulting in lower quality text (and one that differs from what's used everywhere else).
Then I ran into theQGraphicsOpacityEffect
effect, but this again causes some issues with the text quality.At the moment I paint images of gradients going from the widget's background color to transparency over both ends of the text, but this doesn't work if the actual background behind the widget differs from the window color, and would be completely wrong if the background is not uniform (e.g. overlaid widgets).
Here's the relevant widget code with an example window, with the painting being done in the
paint_event
method# Auto_Neutron # Copyright (C) 2019 Numerlor # # Auto_Neutron is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # Auto_Neutron is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with Auto_Neutron. If not, see <https://www.gnu.org/licenses/>. import sys import typing as t from PySide6 import QtCore, QtGui, QtWidgets # no true_property because Qt doesn't pick them up for overriding in subclasses # TODO: add when fixed # noinspection PyUnresolvedReferences from __feature__ import snake_case # noqa F401 class PlainTextScroller(QtWidgets.QWidget): """ A plain text display widget, which scrolls horizontally on hover if the entire text cannot fit into its size. The `fade_width` argument can be used to control the width of the fade-in and fade-out gradients. `scroll_step` defines how much the text moves for each scroll interval. `scroll_interval` controls the scroll interval, in ms. """ def __init__( self, parent: t.Optional[QtWidgets.QWidget] = None, *, text: str = "", fade_width: int = 30, scroll_step: float = 1 / 4, scroll_interval: int = 4, ): super().__init__(parent) self._fade_width = fade_width self._scroll_pos = 0 self.scroll_step = scroll_step self._scroll_interval = scroll_interval self._static_text = QtGui.QStaticText(text) self._static_text.set_text_format(QtCore.Qt.PlainText) self._fade_in_image, self._fade_out_image = self._create_fade_images() self._scroll_timer = QtCore.QTimer(self) self._scroll_timer.set_interval(scroll_interval) self._scroll_timer.set_timer_type(QtCore.Qt.TimerType.PreciseTimer) self._scroll_timer.timeout.connect(self._reposition) self._reset_timer = QtCore.QTimer(self) self._reset_timer.set_interval(2000) self._reset_timer.set_single_shot(True) self._reset_timer.timeout.connect(self._reset_pos) self._delay_scroll_start_timer = QtCore.QTimer(self) self._delay_scroll_start_timer.set_interval(750) self._delay_scroll_start_timer.set_single_shot(True) self._delay_scroll_start_timer.timeout.connect(self._scroll_timer.start) @property def text(self) -> str: """Return the widget's text.""" return self._static_text.text() @text.setter def text(self, text: str) -> None: """Set widget's text to `text`.""" self._static_text.set_text(text) self._refresh_fade_images() self.update_geometry() self.update() @property def font(self) -> QtGui.QFont: """Return the widget's font.""" return super().font() @font.setter def font(self, font: QtGui.QFont) -> None: """Set the widget's font.""" super().set_font(font) # TODO self._fade_in_image, self._fade_out_image = self._create_fade_images() @property def fade_width(self) -> int: """Return the fade rect width.""" return self._fade_width @fade_width.setter def fade_width(self, width: int) -> None: """Set the fade rect width to `width`.""" self._fade_width = width self._fade_in_image, self._fade_out_image = self._create_fade_images() @property def scroll_interval(self) -> int: """Return the current scroll interval.""" return self._scroll_interval @scroll_interval.setter def scroll_interval(self, interval: int): """Set a new scroll interval.""" self._scroll_timer.set_interval(interval) self._scroll_interval = interval def size_hint(self) -> QtCore.QSize: """ Return the size hint. Equal to the text's size. """ return self._text_size def minimum_size_hint(self) -> QtCore.QSize: """ Return the minimum size hint. Height is equal to the text's, width is the text's width with a minimum of 35. """ text_size = self._text_size return QtCore.QSize(min(40, text_size.width()), text_size.height()) def enter_event(self, event: QtGui.QEnterEvent) -> None: """Start scrolling if text doesn't fit on entry.""" super().enter_event(event) if self._text_size.width() > self.size().width(): self._delay_scroll_start_timer.set_interval(500) self._delay_scroll_start_timer.start() def leave_event(self, event: QtGui.QEnterEvent) -> None: """Cancel any scrolling on leave.""" super().leave_event(event) self._scroll_pos = 0 self._scroll_timer.stop() self._reset_timer.stop() self._delay_scroll_start_timer.stop() self.update() def _reposition(self) -> None: """ Move the text and redraw. If the text is not at the end, move the text to the left, otherwise stop moving and start the reset timer. """ if self._text_size.width() - self._scroll_pos < self.width(): # Reached end of text. self._scroll_timer.stop() self._reset_timer.start() else: self._scroll_pos += self.scroll_step self.update() def _reset_pos(self) -> None: """Reset the text to its initial position, and start the scroll timer which will start scrolling in 750ms.""" self._scroll_pos = 0 self.update() self._delay_scroll_start_timer.set_interval(750) self._delay_scroll_start_timer.start() 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, ) def _create_fade_images(self) -> tuple[QtGui.QImage, QtGui.QImage]: """Return a fade-in image and a fade-out image of `self.fade_width` width.""" fade_in_image = QtGui.QImage( self.fade_width, self._text_size.height(), QtGui.QImage.Format_ARGB32_Premultiplied, ) if fade_in_image.is_null(): raise MemoryError("Unable to allocate QImage.") fade_out_image = QtGui.QImage( self.fade_width, self._text_size.height(), QtGui.QImage.Format_ARGB32_Premultiplied, ) if fade_out_image.is_null(): raise MemoryError("Unable to allocate QImage.") # FIXME: use actual transparency instead of using the background color background_color = self.palette().window().color().get_rgb()[:-1] opaque_color = QtGui.QColor(*background_color, 255) fade_in_image.fill(QtCore.Qt.transparent) fade_out_image.fill(QtCore.Qt.transparent) gradient = QtGui.QLinearGradient( QtCore.QPointF(0, 0), QtCore.QPointF(self.fade_width, 0) ) painter = QtGui.QPainter(fade_in_image) painter.set_pen(QtCore.Qt.NoPen) gradient.set_color_at(0, opaque_color) gradient.set_color_at(1, QtCore.Qt.transparent) painter.fill_rect(fade_in_image.rect(), gradient) painter.end() painter.begin(fade_out_image) painter.set_pen(QtCore.Qt.NoPen) gradient.set_color_at(0, QtCore.Qt.transparent) gradient.set_color_at(1, opaque_color) painter.fill_rect(fade_out_image.rect(), gradient) return fade_in_image, fade_out_image def change_event(self, event: QtCore.QEvent) -> None: """Update the fade images if the palette changed.""" if event.type() == QtCore.QEvent.PaletteChange: self._fade_out_image, self._fade_in_image = self._create_fade_images() super().change_event(event) @property def _text_size(self) -> QtCore.QSize: return QtCore.QSize( self.font_metrics().horizontal_advance(self.text), self.font_metrics().height(), ) class Window(QtWidgets.QMainWindow): def __init__(self): super().__init__() self.widget = QtWidgets.QWidget() self.layout_ = QtWidgets.QHBoxLayout(self.widget) self.scroll_text = PlainTextScroller( self.widget, text="This long text string can't fit into the short window because the window is too short to hold this long text string.", ) self.layout_.add_widget(self.scroll_text) self.set_central_widget(self.widget) def create_interrupt_timer() -> QtCore.QTimer: """Interrupt the Qt event loop regularly to let python process signals.""" timer = QtCore.QTimer() timer.set_interval(50) timer.timeout.connect(lambda: None) timer.start() return timer def _exit_on_interrupt(*args): if args[0] is KeyboardInterrupt: app.exit() sys.__excepthook__(*args) app = QtWidgets.QApplication() app.set_style("Fusion") w = Window() w.show() timer = create_interrupt_timer() sys.excepthook = _exit_on_interrupt app.exec()