How to make a QIcon switch appearance without resetting it on the widget?

246 Views Asked by At

Now that dark mode has finally come to Windows with Qt 6.5, I noticed that a lot of my icons don't look too well on a dark background. So I'd like to use different icons for light and dark mode. And, to make things difficult, have the icons change (their appearance) when the user switches mode on his OS.

In order to avoid having to setIcon() on all kinds of widgets all over the place, I thought I'd subclass QIcon and have it change its pixmap on the colorSchemeChanged signal.

class ThemeAwareIcon(QIcon):
    def __init__(self, dark_pixmap, light_pixmap, *args):
        super().__init__(*args)
        self.dark_pm = dark_pixmap
        self.light_pm = light_pixmap
        self.app = QApplication.instance()
        self.app.styleHints().colorSchemeChanged.connect(self.set_appropriate_pixmap)
        self.set_appropriate_pixmap()

    def set_appropriate_pixmap(self):
        current_scheme = self.app.styleHints().colorScheme()
        pm = self.dark_pm if current_scheme == Qt.ColorScheme.Dark else self.light_pm
        self.addPixmap(pm, QIcon.Mode.Normal, QIcon.State.On)

This works almost as intended; pixmaps are changed upon the signal. It's just that the changed pixmap isn't displayed on the widget that the icon is set on. The only way I found to make the change visible is to reset the icon on the widget, and that is what I was trying to avoid in the first place.

So, can my icon class be rescued somehow or is what I want just not possible this way?

1

There are 1 best solutions below

5
mahkitah On

After a bit of a struggle, I found out how to use QIconEngine to get the icons to switch on their own.

Here's a bit a background info for people who (like I did) struggle with the concept of QIcon, QIconEngine, and the widgets they're set upon. This explanation may not be 100% accurate, but 100% accuracy has the tendancy to be 0% understandable for beginners.

A QIcon is an interface for a QIconEngine. You can add image files, retrieve pixmaps etc. through the QIcon, but as soon as the icon is set on a widget, the QIcon object becomes useless. The widget only interacts with the engine, not with QIcon. So if you want to customise the behaviour of an icon when it's set on a widget you have to do it in the engine. In most cases the widget will retrieve the icons from the engine by calling pixmap() with the desired size, mode, and state, but rumour has it that widgets can also call paint()

Here is a very basic general purpose implementation. Any signal can be used to trigger the switch.

In case of switching at colorscheme change, there's no need to pass the signal to the engine cause it's globally available.

This example 'offloads' the pixmap generation to regular QIcons. This may not be the most efficient way but you'll get default behaviour in all circumstances.

class SwitchingEngine(QIconEngine):
    offload = {}

    def __init__(self, file_name, f1, f2, sig):
        super().__init__()
        self.file_name = file_name
        if self.file_name not in self.offload:
            self.offload[self.file_name] = {
                True: QIcon(f1),
                False: QIcon(f2),
            }
        self.switch = True
        sig.connect(lambda: setattr(self, 'switch', not self.switch))

    def pixmap(self, size: QSize, mode: QIcon.Mode, state: QIcon.State):
        return self.offload[self.file_name][self.switch].pixmap(size, mode, state)

    def paint(self, painter: QPainter, rect: QRect, mode: QIcon.Mode, state: QIcon.State):
        return self.offload[self.file_name][self.switch].paint(painter, rect, mode, state)

    
class SwitchingIcon(QIcon):
    def __init__(self, file_name, sig):
        f1 = f'switch1/{file_name}'
        f2 = f'switch2/{file_name}'
        engine = SwitchingEngine(file_name, f1, f2, sig)
        super().__init__(engine)

and as a bonus a little 'bring your own icon files' test app.

import sys
from PyQt5.QtWidgets import QApplication, QWidget, QPushButton, QToolButton, QComboBox, QVBoxLayout, QHBoxLayout, QStyleFactory
from PyQt5.QtGui import QIcon, QIconEngine
from PyQt5.QtCore import QSize

class Window(QWidget):
    def __init__(self):
        super().__init__()
        sw = QPushButton('switch')
        dis = QPushButton('disable')

        but1 = QToolButton()
        but2 = QToolButton()
        but3 = QToolButton()

        icon1 = SwitchingIcon('fn1', sw.clicked)
        icon2 = SwitchingIcon('fn2', sw.clicked)
        icon3 = SwitchingIcon('fn3', sw.clicked)

        but1.setIcon(icon1)
        but2.setIcon(icon2)
        but3.setIcon(icon3)

        sw.clicked.connect(self.update)
        dis.clicked.connect(lambda: but1.setEnabled(not but1.isEnabled()))
        dis.clicked.connect(lambda: but2.setEnabled(not but2.isEnabled()))
        dis.clicked.connect(lambda: but3.setEnabled(not but3.isEnabled()))

        cmb = QComboBox()
        cmb.addItems(QStyleFactory.keys())
        cmb.currentTextChanged.connect(QApplication.instance().setStyle)

        tbs = QHBoxLayout()
        tbs.addWidget(but1)
        tbs.addWidget(but2)
        tbs.addWidget(but3)
        lay = QVBoxLayout(self)
        lay.addWidget(cmb)
        lay.addLayout(tbs)
        lay.addWidget(sw)
        lay.addWidget(dis)

app = QApplication([])
window = Window()
window.show()
sys.exit(app.exec())