QGraphicsEffect on pyqt, blinking a button

822 views Asked by At

I am building a GUI on python and pyqt. The GUI has a lot of pushbuttons, generated through class LED, meaning each led has 3 buttons, for an n number of leds.

In a few of the buttons, I want an effect that changes the opacity of the pushbutton, in a loop from 0 to 1 and back again, so it disappears and appears. I need only one process to manage all, so the effect starts at same time for every button and all blink at the same time.

I've managed to achieve that, through qgraphicseffect in a thread, iterating through a list. The problem is that after a few minutes, the effect stops, although the thread is still running (print(opacity_level)). more pushbuttons with the effect makes even shorter duration. Clicking any button, even others without effect, restarts the gui animation.

My small research in threading on pyqt made me implement this thread manager, although I do not fully understand it.

class WorkerSignals(QtCore.QObject):
    finished = QtCore.pyqtSignal()
    error = QtCore.pyqtSignal(tuple)
    result = QtCore.pyqtSignal(object)
    progress = QtCore.pyqtSignal(tuple)

class Worker(QtCore.QRunnable):
    '''
    Worker thread
    Inherits from QRunnable to handler worker thread setup, signals and wrap-up.
    '''
    def __init__(self, fn, *args, **kwargs):
        super(Worker, self).__init__()
        # Store constructor arguments (re-used for processing)
        self.fn = fn
        self.args = args
        self.kwargs = kwargs
        self.signals = WorkerSignals()

        # Add the callback to our kwargs
        self.kwargs['progress_callback'] = self.signals.progress

    @pyqtSlot()
    def run(self):
        '''
        Initialise the runner function with passed args, kwargs.
        '''
        # Retrieve args/kwargs here; and fire processing using them
        try:
            result = self.fn(*self.args, **self.kwargs)
        except:
            traceback.print_exc()
            exctype, value = sys.exc_info()[:2]
            self.signals.error.emit((exctype, value, traceback.format_exc()))
        else:
            self.signals.result.emit(result)  # Return the result of the processing
        finally:
            self.signals.finished.emit()  # Done

Next the leds class

class LEDs:
    def __init__(self,name,group,frame):
        self.opacity_effect = QtWidgets.QGraphicsOpacityEffect()
        self.button_auto = QtWidgets.QPushButton()
        self.button_auto.setObjectName("button_auto_neutral")
        self.button_auto.clicked.connect(lambda state, x=self: self.AutoMode())
    
    def AutoMode(self):
        print(self.name,"Automode")
        if len(settings.blink) ==0: # start thread only if no previous thread, both thread and 
                                        this reference the size of settings.blink, so not ideal.
            print("start thread")
            settings.ledAutomode()

        settings.blink.append(self)

And finally the settings class, which has the thread with the effect performing action. There is a second thread, which handles the icon of the button, accordingly with a timetable.

class Settings:
    def __init__(self):
        self.blink=[]

    def ledAutomode(self):

        def blink(progress_callback):
            print("opacity")
            op_up=[x/100 for x in range(0,101,5)]
            op_down=op_up[::-1]; op_down=op_down[1:-1]; opacity=op_up+op_down

            while len(self.blink) !=0:
                for i in opacity:
                    print(i)
                    QtCore.QThread.msleep(80)
                    for led in self.blink:
                        led.opacity_effect.setOpacity(i)

        def timeCheck(progress_callback):
            while len(self.blink) != 0:
                QtCore.QThread.msleep(500)
                for led in self.blink:
                    matrix = [v for v in settings.leds_config[led.group][led.name]["Timetable"]]
                    matrix_time=[]

                    ...
                    # some code
                    ...

                    if sum(led_on_time):
                        led.button_auto.setObjectName("button_auto_on")
                        led.button_auto.setStyleSheet(ex.stylesheet)

                    else:
                        led.button_auto.setObjectName("button_auto_off")
                        led.button_auto.setStyleSheet(ex.stylesheet)
                    QtCore.QThread.msleep(int(30000/len(self.blink)))


        worker = Worker(blink)  # Any other args, kwargs are passed to the run function
        ex.threadpool.start(worker)
        worker2 = Worker(timeCheck)  # Any other args, kwargs are passed to the run function
        ex.threadpool.start(worker2)

So, perhaps a limitation on qgraphicseffect, or some problem with the thread (although its keeps printing), or I made some error.

I've read about subclassing the qgraphicseffect but I don't know if that solves the problem. If anyone has another implementation, always eager to learn.

Grateful for your time.

1

There are 1 answers

10
musicamante On BEST ANSWER

Widgets are not thread-safe.
They cannot be created nor accessed from external threads. While it "sometimes" works, doing it is wrong and usually leads to unexpected behavior, drawing artifacts and even fatal crash.

That said, you're making the whole process incredibly and unnecessarily convoluted, much more than it should be, most importantly because Qt already provides both timed events (QTimer) and animations.

class FadeButton(QtWidgets.QPushButton):
    def __init__(self):
        super().__init__()
        self.effect = QtWidgets.QGraphicsOpacityEffect(opacity=1.0)
        self.setGraphicsEffect(self.effect)
        self.animation = QtCore.QPropertyAnimation(self.effect, b'opacity')
        self.animation.setStartValue(1.0)
        self.animation.setEndValue(0.0)
        self.animation.setDuration(1500)
        self.animation.finished.connect(self.checkAnimation)

        self.clicked.connect(self.startAnimation)

    def startAnimation(self):
        self.animation.stop()
        self.animation.setDirection(self.animation.Forward)
        self.animation.start()

    def checkAnimation(self):
        if not self.animation.value():
            self.animation.setDirection(self.animation.Backward)
            self.animation.start()
        else:
            self.animation.setDirection(self.animation.Forward)

If you want to synchronize opacity amongst many widgets, there are various possibilities, but a QVariantAnimation that updates all opacities is probably the easier choice:

class LEDs(QtWidgets.QWidget):
    def __init__(self, parent=None):
        super().__init__(parent)
        layout = QtWidgets.QHBoxLayout(self)

        self.animation = QtCore.QVariantAnimation()
        self.animation.setStartValue(1.0)
        self.animation.setEndValue(0.0)
        self.animation.setDuration(1500)
        self.animation.valueChanged.connect(self.updateOpacity)
        self.animation.finished.connect(self.checkAnimation)

        self.buttons = []
        for i in range(3):
            button = QtWidgets.QPushButton()
            self.buttons.append(button)
            layout.addWidget(button)
            effect = QtWidgets.QGraphicsOpacityEffect(opacity=1.0)
            button.setGraphicsEffect(effect)
            button.clicked.connect(self.startAnimation)

    # ... as above ...

    def updateOpacity(self, opacity):
        for button in self.buttons:
            button.graphicsEffect().setOpacity(opacity)

Note that you shouldn't change the object name of a widget during runtime, and doing it only because you want to update the stylesheet is wrong. You either use a different stylesheet, or you use the property selector:

    QPushButton {
        /* default state */
        background: #ababab;
    }
    QPushButton[auto_on="true"] {
        /* "on" state */
        background: #dadada;
    }
class FadeButton(QtWidgets.QPushButton):
    def __init__(self):
        super().__init__()
        # ...
        self.setProperty('auto_on', False)

    def setAuto(self, state):
        self.setProperty('auto_on', state)
        self.setStyleSheet(self.styleSheet())