Search code examples
pythonqtpyside6

QPushButton brief delay when clicked event connect to disable itself


I've observed this rather annoying behavior several times (at least on Windows 10), and I just can't figure out a workaround.

I have come up with a very simple test case:

Derp.ui

def pba():
    mainWindow.pushButtonA.setEnabled(False)
    mainWindow.pushButtonB.setEnabled(True)

def pbb():
    mainWindow.pushButtonB.setEnabled(False)
    mainWindow.pushButtonA.setEnabled(True)

app = QtWidgets.QApplication(sys.argv)
loader = QUiLoader()
mainWindow = loader.load("Derp.ui", None)

mainWindow.pushButtonA.clicked.connect(pba)
mainWindow.pushButtonB.clicked.connect(pbb)

mainWindow.show()
app.exec()

It can be readily seen that there is a brief delay before the pushbutton's clicked signal's connected function can disable the clicked button. Also, there is no such delay if the connected function is configured to disable the other button.

I understand that the function connected to the clicked signal is blocking the GUI loop, but in this case the connected functions are extremely brief and short in duration. (Anyway, starting a new thread to execute setEnabled(False) changes nothing.)

If my eyes are not misleading me, it seems that the disabled change is placed in a queue, and before it can be visually displayed the pushbutton first has to visually change states in response to its clicked signal. But I'm not super clear on exactly what is happening behind the scenes when I click a QPushButton.

So, how exactly can I get around this behavior? I simply want the button to be disabled immediately upon being clicked.


Solution

  • What you are describing is exactly the expected behavior, and is not caused by the blocking of the GUI: on the contrary, it's exactly done to prevent what would happen in case the GUI becomes blocked when the signal is emitted.

    A clicked signal is emitted when the mouse button is released (specifically, when released inside the button area), meaning that the button has become unpressed, which automatically triggers an immediate repainting that will reflect that state: show the button as unpressed.

    That is important in the logic of events, and that's exactly to prevent the blocking of the GUI: the repainting must happen before emitting the signal, so that the button is properly displaying the current state; by default, signals are directly connected when transmitter and receiver belong to the same thread, meaning that the control is not returned until the connected function is completed: so, with this approach, the button will properly show its new (unpressed) state, even if the signal is connected to something that would potentially block the event loop.

    The sequence is:

    1. mouse button release event: if the button is not the left one, the event is ignored;
    2. if the mouse button is the left one, the button widget becomes unpressed: isDown() would return False;
    3. immediately repaint the button as unpressed, using repaint() instead of update() (which would schedule a repaint by queuing a Paint event), in order to properly display the new state;
    4. paintEvent() is called, which will init a QStyleOptionButton using various information about the button (including its isDown() state) and use the current style to paint the button with that;
    5. emit the released signal;
    6. if the mouse is within the button area, finally emit the clicked signal;

    Note that, while your solution may be fine for your case, it's not completely valid or acceptable, at least for the following reasons:

    • you are not checking the mouse button, so that will make the button emit the signal when using any mouse button;
    • you are not checking if the mouse is within the button area, so you will emit the signal even if the user has left the button;
    • the underlying style may use animations that will still queue the painting even if the button is already in disabled state;
    • a disabled widget does not accept user events, and, by default, a not accepted event will be handled by its parent (until any of the parents in the tree accepts it); the default implementation of an enabled QAbstractButton, instead, automatically accepts the event if it is clicked with the left mouse button; if the button is disabled in mousePressEvent() and you call the base implementation after that, the event will become not accepted, meaning that, potentially, any parent above it would receive the event and eventually handle it;
    • buttons can also be clicked by pressing the space bar when they have focus;

    Also note that disabling a button automatically sets it as unpressed (automatically emitting the released signal).

    A proper and more comprehensive implementation would look like the following:

    class QDisablingPushButton(QPushButton):
        def mouseReleaseEvent(self, event):
            if (
                event.button() == Qt.LeftButton
                and self.rect().contains(event.pos())
            ):
                self.setEnabled(False) # release is implicit
                self.repaint()
                self.clicked.emit()
            else:
                super().mouseReleaseEvent(event)
    
        def keyReleaseEvent(self, event):
            if (
                event.key() in (Qt.Key_Select, Qt.Key_Space)
                and not event.isAutoRepeat()
                and self.isDown()
            ):
                self.setEnabled(False)
                self.repaint()
                self.clicked.emit()
            else:
                super().keyReleaseEvent(event)
    

    Finally, there's a catch. Emitting the clicked signal on your own prevents any QButtonGroup to properly emit the related idClicked and buttonClicked signals.

    If you need to use button groups, you should consider a slightly different implementation:

    class Button(QPushButton):
        def disableAndClick(self):
            self.setEnabled(False) # release signal is implicit
            self.repaint()
            self.clicked.emit()
            group = self.group()
            if group:
                try: # for Qt < 5.15
                    group.idClicked.emit(group.id(self))
                except AttributeError:
                    group.buttonClicked[int].emit(group.id(self))
                    # to connect to this signal, you must use:
                    # group.buttonClicked[int].connect(function)
                group.buttonClicked.emit(self)
    
        def mouseReleaseEvent(self, event):
            if (
                event.button() == Qt.LeftButton
                and self.rect().contains(event.pos())
            ):
                self.disableAndClick()
            else:
                super().mouseReleaseEvent(event)
    
        def keyReleaseEvent(self, event):
            if (
                event.key() in (Qt.Key_Select, Qt.Key_Space)
                and not event.isAutoRepeat()
                and self.isDown()
            ):
                self.disableAndClick()
            else:
                super().keyReleaseEvent(event)